mirror of
https://github.com/stashapp/stash.git
synced 2026-01-02 21:52:26 +01:00
Implement merging of performers
This commit is contained in:
parent
15bf28d5be
commit
eeb97d55e2
17 changed files with 1129 additions and 57 deletions
|
|
@ -366,6 +366,7 @@ type Mutation {
|
|||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
performersDestroy(ids: [ID!]!): Boolean!
|
||||
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
|
||||
performerMerge(input: PerformerMergeInput!): Performer
|
||||
|
||||
studioCreate(input: StudioCreateInput!): Studio
|
||||
studioUpdate(input: StudioUpdateInput!): Studio
|
||||
|
|
|
|||
|
|
@ -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,12 +2,15 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
|
@ -135,7 +138,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
return r.getPerformer(ctx, newPerformer.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if translator.hasField("url") {
|
||||
return fmt.Errorf("url field must not be included if urls is included")
|
||||
|
|
@ -150,7 +153,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs *LegacyURLs, updatedPerformer *models.PerformerPartial) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
// we need to be careful with URL/Twitter/Instagram
|
||||
|
|
@ -169,23 +172,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
|||
existingURLs := p.URLs.List()
|
||||
|
||||
// performer partial URLs should be empty
|
||||
if legacyURL.Set {
|
||||
if legacyURLs.URL.Set {
|
||||
replaced := false
|
||||
for i, url := range existingURLs {
|
||||
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
|
||||
existingURLs[i] = legacyURL.Value
|
||||
existingURLs[i] = legacyURLs.URL.Value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
existingURLs = append(existingURLs, legacyURL.Value)
|
||||
existingURLs = append(existingURLs, legacyURLs.URL.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyTwitter.Set {
|
||||
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
|
||||
if legacyURLs.Twitter.Set {
|
||||
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
|
||||
found := false
|
||||
// find and replace the first twitter URL
|
||||
for i, url := range existingURLs {
|
||||
|
|
@ -200,9 +203,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
|||
existingURLs = append(existingURLs, value)
|
||||
}
|
||||
}
|
||||
if legacyInstagram.Set {
|
||||
if legacyURLs.Instagram.Set {
|
||||
found := false
|
||||
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
|
||||
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
|
||||
// find and replace the first instagram URL
|
||||
for i, url := range existingURLs {
|
||||
if performer.IsInstagramURL(url) {
|
||||
|
|
@ -225,16 +228,17 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
type LegacyURLs struct {
|
||||
URL models.OptionalString
|
||||
Twitter models.OptionalString
|
||||
Instagram models.OptionalString
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
func (u *LegacyURLs) AnySet() bool {
|
||||
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
|
||||
}
|
||||
|
||||
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, *LegacyURLs, error) {
|
||||
// Populate performer from the input
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
|
|
@ -259,26 +263,30 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||
|
||||
var err error
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
var legacyURLs = LegacyURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
return nil, nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
}
|
||||
updatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, "death_date")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting death date: %w", err)
|
||||
return nil, nil, fmt.Errorf("converting death date: %w", err)
|
||||
}
|
||||
|
||||
// prefer height_cm over height
|
||||
|
|
@ -293,7 +301,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
|
||||
updatedPerformer.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
return nil, nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
updatedPerformer.CustomFields = input.CustomFields
|
||||
|
|
@ -301,6 +309,24 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
|
||||
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
|
||||
|
||||
return &updatedPerformer, &legacyURLs, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
updatedPerformer, legacyURLs, err := performerPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
|
|
@ -314,8 +340,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -381,16 +407,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
var legacyURLs = LegacyURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
|
|
@ -423,17 +451,17 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
qb := r.repository.Performer
|
||||
|
||||
for _, performerID := range performerIDs {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, &legacyURLs, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
|
||||
if err := performer.ValidateUpdate(ctx, performerID, &updatedPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
performer, err := qb.UpdatePartial(ctx, performerID, updatedPerformer)
|
||||
performer, err := qb.UpdatePartial(ctx, performerID, &updatedPerformer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -504,3 +532,93 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
|
|||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
|
||||
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting source ids: %w", err)
|
||||
}
|
||||
|
||||
// ensure source ids are unique
|
||||
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
|
||||
|
||||
destID, err := strconv.Atoi(input.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting destination id: %w", err)
|
||||
}
|
||||
|
||||
// ensure destination is not in source list
|
||||
if slices.Contains(srcIDs, destID) {
|
||||
return nil, errors.New("destination scene cannot be in source list")
|
||||
}
|
||||
|
||||
var values *models.PerformerPartial
|
||||
var imageData []byte
|
||||
var legacyURLs *LegacyURLs
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, legacyURLs, err = performerPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if legacyURLs != nil && legacyURLs.AnySet() {
|
||||
return nil, errors.New("Merging legacy performer URLs is not supported")
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewPerformerPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var dest *models.Performer
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
dest, err = qb.Find(ctx, destID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding destination scene ID %d: %w", destID, err)
|
||||
}
|
||||
|
||||
sources, err := qb.FindMany(ctx, srcIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding source scenes: %w", err)
|
||||
}
|
||||
|
||||
for _, src := range sources {
|
||||
if err := src.LoadRelationships(ctx, qb); err != nil {
|
||||
return fmt.Errorf("loading performer relationships from %d: %w", src.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, destID, values); err != nil {
|
||||
return fmt.Errorf("updating performer: %w", err)
|
||||
}
|
||||
|
||||
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
|
||||
return fmt.Errorf("merging performers: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,11 +205,11 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
|||
}
|
||||
}
|
||||
|
||||
if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil {
|
||||
if err := performer.ValidateUpdate(ctx, t.performer.ID, &partial, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {
|
||||
if _, err := qb.UpdatePartial(ctx, t.performer.ID, &partial); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// Merge provides a mock function with given fields: ctx, source, destination
|
||||
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
|
||||
ret := _m.Called(ctx, source, destination)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
|
||||
r0 = rf(ctx, source, destination)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
|
||||
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
|
||||
ret := _m.Called(ctx, performerFilter, findFilter)
|
||||
|
|
@ -576,11 +590,11 @@ func (_m *PerformerReaderWriter) UpdateImage(ctx context.Context, performerID in
|
|||
}
|
||||
|
||||
// UpdatePartial provides a mock function with given fields: ctx, id, updatedPerformer
|
||||
func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer models.PerformerPartial) (*models.Performer, error) {
|
||||
func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer *models.PerformerPartial) (*models.Performer, error) {
|
||||
ret := _m.Called(ctx, id, updatedPerformer)
|
||||
|
||||
var r0 *models.Performer
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, models.PerformerPartial) *models.Performer); ok {
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, *models.PerformerPartial) *models.Performer); ok {
|
||||
r0 = rf(ctx, id, updatedPerformer)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
|
|
@ -589,7 +603,7 @@ func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, upda
|
|||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int, models.PerformerPartial) error); ok {
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int, *models.PerformerPartial) error); ok {
|
||||
r1 = rf(ctx, id, updatedPerformer)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ type PerformerCreator interface {
|
|||
// PerformerUpdater provides methods to update performers.
|
||||
type PerformerUpdater interface {
|
||||
Update(ctx context.Context, updatedPerformer *UpdatePerformerInput) error
|
||||
UpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error)
|
||||
UpdatePartial(ctx context.Context, id int, updatedPerformer *PerformerPartial) (*Performer, error)
|
||||
UpdateImage(ctx context.Context, performerID int, image []byte) error
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +92,8 @@ type PerformerWriter interface {
|
|||
PerformerCreator
|
||||
PerformerUpdater
|
||||
PerformerDestroyer
|
||||
|
||||
Merge(ctx context.Context, source []int, destination int) error
|
||||
}
|
||||
|
||||
// PerformerReaderWriter provides all performer methods.
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func ValidateCreate(ctx context.Context, performer models.Performer, qb models.P
|
|||
return nil
|
||||
}
|
||||
|
||||
func ValidateUpdate(ctx context.Context, id int, partial models.PerformerPartial, qb models.PerformerReader) error {
|
||||
func ValidateUpdate(ctx context.Context, id int, partial *models.PerformerPartial, qb models.PerformerReader) error {
|
||||
existing, err := qb.Find(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ type performerRowRecord struct {
|
|||
updateRecord
|
||||
}
|
||||
|
||||
func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
|
||||
func (r *performerRowRecord) fromPartial(o *models.PerformerPartial) {
|
||||
r.setString("name", o.Name)
|
||||
r.setNullString("disambiguation", o.Disambiguation)
|
||||
r.setNullString("gender", o.Gender)
|
||||
|
|
@ -302,7 +302,7 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePe
|
|||
return nil
|
||||
}
|
||||
|
||||
func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) {
|
||||
func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial *models.PerformerPartial) (*models.Performer, error) {
|
||||
r := performerRowRecord{
|
||||
updateRecord{
|
||||
Record: make(exp.Record),
|
||||
|
|
@ -864,3 +864,56 @@ func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bo
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
inBinding := getInBinding(len(source))
|
||||
|
||||
args := []interface{}{destination}
|
||||
srcArgs := make([]interface{}, len(source))
|
||||
for i, id := range source {
|
||||
if id == destination {
|
||||
return errors.New("cannot merge where source == destination")
|
||||
}
|
||||
srcArgs[i] = id
|
||||
}
|
||||
|
||||
args = append(args, srcArgs...)
|
||||
|
||||
performerTables := map[string]string{
|
||||
performersScenesTable: sceneIDColumn,
|
||||
performersGalleriesTable: galleryIDColumn,
|
||||
performersImagesTable: imageIDColumn,
|
||||
performersTagsTable: tagIDColumn,
|
||||
}
|
||||
|
||||
args = append(args, destination)
|
||||
for table, idColumn := range performerTables {
|
||||
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
|
||||
SET performer_id = ?
|
||||
WHERE performer_id IN `+inBinding+`
|
||||
AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete source performer ids from the table where they couldn't be set
|
||||
if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range source {
|
||||
err := qb.Destroy(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -611,7 +611,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
|||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
|
||||
got, err := qb.UpdatePartial(ctx, tt.id, &tt.partial)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("PerformerStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
|
@ -696,7 +696,7 @@ func Test_PerformerStore_UpdatePartialCustomFields(t *testing.T) {
|
|||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
|
||||
_, err := qb.UpdatePartial(ctx, tt.id, &tt.partial)
|
||||
if err != nil {
|
||||
t.Errorf("PerformerStore.UpdatePartial() error = %v", err)
|
||||
return
|
||||
|
|
@ -2092,7 +2092,7 @@ func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performe
|
|||
|
||||
// update stash ids and ensure was updated
|
||||
var err error
|
||||
s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{
|
||||
s, err = qb.UpdatePartial(ctx, s.ID, &models.PerformerPartial{
|
||||
StashIDs: &models.UpdateStashIDs{
|
||||
StashIDs: []models.StashID{stashID},
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
|
|
@ -2110,7 +2110,7 @@ func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performe
|
|||
assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List())
|
||||
|
||||
// remove stash ids and ensure was updated
|
||||
s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{
|
||||
s, err = qb.UpdatePartial(ctx, s.ID, &models.PerformerPartial{
|
||||
StashIDs: &models.UpdateStashIDs{
|
||||
StashIDs: []models.StashID{stashID},
|
||||
Mode: models.RelationshipUpdateModeRemove,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -248,6 +249,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
|||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isMerging, setIsMerging] = useState<boolean>(false);
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
|
@ -283,6 +285,26 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
|||
}
|
||||
}
|
||||
|
||||
function renderMergeButton() {
|
||||
return (
|
||||
<Button variant="secondary" onClick={() => setIsMerging(true)}>
|
||||
<FormattedMessage id="actions.merge" />
|
||||
...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMergeDialog() {
|
||||
if (!performer.id) return;
|
||||
return (
|
||||
<PerformerMergeModal
|
||||
show={isMerging}
|
||||
onClose={() => setIsMerging(false)}
|
||||
performers={[performer]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui.ratingSystemOptions?.type,
|
||||
|
|
@ -462,9 +484,12 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
|||
onImageChange={() => {}}
|
||||
classNames="mb-2"
|
||||
customButtons={
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
<>
|
||||
{renderMergeButton()}
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
></DetailsEditNavbar>
|
||||
</Row>
|
||||
|
|
@ -492,6 +517,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMergeDialog()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function renderScrapedGender(
|
|||
);
|
||||
}
|
||||
|
||||
function renderScrapedGenderRow(
|
||||
export function renderScrapedGenderRow(
|
||||
title: string,
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void
|
||||
|
|
@ -105,7 +105,7 @@ function renderScrapedCircumcised(
|
|||
);
|
||||
}
|
||||
|
||||
function renderScrapedCircumcisedRow(
|
||||
export function renderScrapedCircumcisedRow(
|
||||
title: string,
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
|
||||
import { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
||||
|
|
@ -21,6 +22,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog";
|
|||
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { PerformerCardGrid } from "./PerformerCardGrid";
|
||||
import { PerformerMergeModal } from "./PerformerMergeDialog";
|
||||
import { View } from "../List/views";
|
||||
|
||||
function getItems(result: GQL.FindPerformersQueryResult) {
|
||||
|
|
@ -169,6 +171,9 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
}) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [mergePerformers, setMergePerformers] = useState<
|
||||
GQL.SelectPerformerDataFragment[] | undefined
|
||||
>(undefined);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
|
||||
|
|
@ -179,6 +184,11 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
text: intl.formatMessage({ id: "actions.open_random" }),
|
||||
onClick: openRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: merge,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
|
|
@ -221,6 +231,18 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
}
|
||||
}
|
||||
|
||||
async function merge(
|
||||
result: GQL.FindPerformersQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
const selected =
|
||||
result.data?.findPerformers.performers.filter((p) =>
|
||||
selectedIds.has(p.id)
|
||||
) ?? [];
|
||||
setMergePerformers(selected);
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
|
|
@ -237,6 +259,23 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
|
||||
) {
|
||||
function renderMergeDialog() {
|
||||
if (mergePerformers) {
|
||||
return (
|
||||
<PerformerMergeModal
|
||||
performers={mergePerformers}
|
||||
onClose={(mergedID?: string) => {
|
||||
setMergePerformers(undefined);
|
||||
if (mergedID) {
|
||||
history.push(NavUtils.makePerformerScenesUrl({ id: mergedID }));
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPerformerExportDialog() {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
|
|
@ -287,6 +326,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{renderMergeDialog()}
|
||||
{maybeRenderPerformerExportDialog()}
|
||||
{renderPerformers()}
|
||||
</>
|
||||
|
|
|
|||
765
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal file
765
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
import { Form, Col, Row, Button } from "react-bootstrap";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import {
|
||||
circumcisedToString,
|
||||
stringToCircumcised,
|
||||
} from "src/utils/circumcised";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { genderToString, stringToGender } from "src/utils/gender";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import {
|
||||
mutatePerformerMerge,
|
||||
queryFindPerformer,
|
||||
queryFindPerformersByID,
|
||||
} from "src/core/StashService";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ScrapeDialog,
|
||||
ScrapedImageRow,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedStringListRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import {
|
||||
ObjectListScrapeResult,
|
||||
ScrapeResult,
|
||||
hasScrapedValues,
|
||||
} from "../Shared/ScrapeDialog/scrapeResult";
|
||||
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import {
|
||||
renderScrapedGenderRow,
|
||||
renderScrapedCircumcisedRow,
|
||||
} from "./PerformerDetails/PerformerScrapeDialog";
|
||||
import { PerformerSelect } from "./PerformerSelect";
|
||||
|
||||
type MergeOptions = {
|
||||
values: GQL.PerformerUpdateInput;
|
||||
};
|
||||
|
||||
interface IPerformerMergeDetailsProps {
|
||||
sources: GQL.PerformerDataFragment[];
|
||||
dest: GQL.PerformerDataFragment;
|
||||
onClose: (options?: MergeOptions) => void;
|
||||
}
|
||||
|
||||
const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
||||
sources,
|
||||
dest,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [name, setName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.name)
|
||||
);
|
||||
const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.disambiguation)
|
||||
);
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.alias_list?.join(", "))
|
||||
);
|
||||
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.birthdate)
|
||||
);
|
||||
const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.death_date)
|
||||
);
|
||||
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.ethnicity)
|
||||
);
|
||||
const [country, setCountry] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.country)
|
||||
);
|
||||
const [hairColor, setHairColor] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.hair_color)
|
||||
);
|
||||
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.eye_color)
|
||||
);
|
||||
const [height, setHeight] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.height_cm?.toString())
|
||||
);
|
||||
const [weight, setWeight] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.weight?.toString())
|
||||
);
|
||||
const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.penis_length?.toString())
|
||||
);
|
||||
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.measurements)
|
||||
);
|
||||
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.fake_tits)
|
||||
);
|
||||
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.career_length)
|
||||
);
|
||||
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.tattoos)
|
||||
);
|
||||
const [piercings, setPiercings] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.piercings)
|
||||
);
|
||||
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(dest.urls)
|
||||
);
|
||||
const [gender, setGender] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(genderToString(dest.gender))
|
||||
);
|
||||
const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(circumcisedToString(dest.circumcised))
|
||||
);
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.details)
|
||||
);
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(dest.tags.map(idToStoredID))
|
||||
)
|
||||
);
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.image_path)
|
||||
);
|
||||
|
||||
function idToStoredID(o: { id: string; name: string }) {
|
||||
return {
|
||||
stored_id: o.id,
|
||||
name: o.name,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate the values for everything
|
||||
// uses the first set value for single value fields, and combines all
|
||||
useEffect(() => {
|
||||
async function loadImages() {
|
||||
const src = sources.find((s) => s.image_path);
|
||||
if (!dest.image_path || !src) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const destData = await ImageUtils.imageToDataURL(dest.image_path);
|
||||
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
|
||||
|
||||
// keep destination image by default
|
||||
const useNewValue = false;
|
||||
setImage(new ScrapeResult(destData, srcData, useNewValue));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setName(
|
||||
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
|
||||
);
|
||||
setDisambiguation(
|
||||
new ScrapeResult(
|
||||
dest.disambiguation,
|
||||
sources.find((s) => s.disambiguation)?.disambiguation,
|
||||
!dest.disambiguation
|
||||
)
|
||||
);
|
||||
setAliases(
|
||||
new ScrapeResult(
|
||||
dest.alias_list?.join(", "),
|
||||
sources.find((s) => s.alias_list)?.alias_list.join(", "),
|
||||
!dest.alias_list?.length
|
||||
)
|
||||
);
|
||||
setBirthdate(
|
||||
new ScrapeResult(
|
||||
dest.birthdate,
|
||||
sources.find((s) => s.birthdate)?.birthdate,
|
||||
!dest.birthdate
|
||||
)
|
||||
);
|
||||
setDeathDate(
|
||||
new ScrapeResult(
|
||||
dest.death_date,
|
||||
sources.find((s) => s.death_date)?.death_date,
|
||||
!dest.death_date
|
||||
)
|
||||
);
|
||||
setEthnicity(
|
||||
new ScrapeResult(
|
||||
dest.ethnicity,
|
||||
sources.find((s) => s.ethnicity)?.ethnicity,
|
||||
!dest.ethnicity
|
||||
)
|
||||
);
|
||||
setCountry(
|
||||
new ScrapeResult(
|
||||
dest.country,
|
||||
sources.find((s) => s.country)?.country,
|
||||
!dest.country
|
||||
)
|
||||
);
|
||||
setHairColor(
|
||||
new ScrapeResult(
|
||||
dest.hair_color,
|
||||
sources.find((s) => s.hair_color)?.hair_color,
|
||||
!dest.hair_color
|
||||
)
|
||||
);
|
||||
setEyeColor(
|
||||
new ScrapeResult(
|
||||
dest.eye_color,
|
||||
sources.find((s) => s.eye_color)?.eye_color,
|
||||
!dest.eye_color
|
||||
)
|
||||
);
|
||||
setHeight(
|
||||
new ScrapeResult(
|
||||
dest.height_cm?.toString(),
|
||||
sources.find((s) => s.height_cm)?.height_cm?.toString(),
|
||||
!dest.height_cm
|
||||
)
|
||||
);
|
||||
setWeight(
|
||||
new ScrapeResult(
|
||||
dest.weight?.toString(),
|
||||
sources.find((s) => s.weight)?.weight?.toString(),
|
||||
!dest.weight
|
||||
)
|
||||
);
|
||||
|
||||
setPenisLength(
|
||||
new ScrapeResult(
|
||||
dest.penis_length?.toString(),
|
||||
sources.find((s) => s.penis_length)?.penis_length?.toString(),
|
||||
!dest.penis_length
|
||||
)
|
||||
);
|
||||
setMeasurements(
|
||||
new ScrapeResult(
|
||||
dest.measurements,
|
||||
sources.find((s) => s.measurements)?.measurements,
|
||||
!dest.measurements
|
||||
)
|
||||
);
|
||||
setFakeTits(
|
||||
new ScrapeResult(
|
||||
dest.fake_tits,
|
||||
sources.find((s) => s.fake_tits)?.fake_tits,
|
||||
!dest.fake_tits
|
||||
)
|
||||
);
|
||||
setCareerLength(
|
||||
new ScrapeResult(
|
||||
dest.career_length,
|
||||
sources.find((s) => s.career_length)?.career_length,
|
||||
!dest.career_length
|
||||
)
|
||||
);
|
||||
setTattoos(
|
||||
new ScrapeResult(
|
||||
dest.tattoos,
|
||||
sources.find((s) => s.tattoos)?.tattoos,
|
||||
!dest.tattoos
|
||||
)
|
||||
);
|
||||
setPiercings(
|
||||
new ScrapeResult(
|
||||
dest.piercings,
|
||||
sources.find((s) => s.piercings)?.piercings,
|
||||
!dest.piercings
|
||||
)
|
||||
);
|
||||
setURLs(
|
||||
new ScrapeResult(
|
||||
dest.urls,
|
||||
sources.find((s) => s.urls)?.urls,
|
||||
!dest.urls?.length
|
||||
)
|
||||
);
|
||||
setGender(
|
||||
new ScrapeResult(
|
||||
genderToString(dest.gender),
|
||||
sources.find((s) => s.gender)?.gender
|
||||
? genderToString(sources.find((s) => s.gender)?.gender)
|
||||
: undefined,
|
||||
!dest.gender
|
||||
)
|
||||
);
|
||||
setCircumcised(
|
||||
new ScrapeResult(
|
||||
circumcisedToString(dest.circumcised),
|
||||
sources.find((s) => s.circumcised)?.circumcised
|
||||
? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised)
|
||||
: undefined,
|
||||
!dest.circumcised
|
||||
)
|
||||
);
|
||||
setDetails(
|
||||
new ScrapeResult(
|
||||
dest.details,
|
||||
sources.find((s) => s.details)?.details,
|
||||
!dest.details
|
||||
)
|
||||
);
|
||||
setImage(
|
||||
new ScrapeResult(
|
||||
dest.image_path,
|
||||
sources.find((s) => s.image_path)?.image_path,
|
||||
!dest.image_path
|
||||
)
|
||||
);
|
||||
|
||||
loadImages();
|
||||
}, [sources, dest]);
|
||||
|
||||
// ensure this is updated if fields are changed
|
||||
const hasValues = useMemo(() => {
|
||||
return hasScrapedValues([
|
||||
name,
|
||||
disambiguation,
|
||||
aliases,
|
||||
birthdate,
|
||||
deathDate,
|
||||
ethnicity,
|
||||
country,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
height,
|
||||
weight,
|
||||
penisLength,
|
||||
measurements,
|
||||
fakeTits,
|
||||
careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
urls,
|
||||
gender,
|
||||
circumcised,
|
||||
details,
|
||||
tags,
|
||||
image,
|
||||
]);
|
||||
}, [
|
||||
name,
|
||||
disambiguation,
|
||||
aliases,
|
||||
birthdate,
|
||||
deathDate,
|
||||
ethnicity,
|
||||
country,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
height,
|
||||
weight,
|
||||
penisLength,
|
||||
measurements,
|
||||
fakeTits,
|
||||
careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
urls,
|
||||
gender,
|
||||
circumcised,
|
||||
details,
|
||||
tags,
|
||||
image,
|
||||
]);
|
||||
|
||||
function renderScrapeRows() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValues) {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage id="dialogs.merge.empty_results" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "name" })}
|
||||
result={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "disambiguation" })}
|
||||
result={disambiguation}
|
||||
onChange={(value) => setDisambiguation(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "aliases" })}
|
||||
result={aliases}
|
||||
onChange={(value) => setAliases(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "birthdate" })}
|
||||
result={birthdate}
|
||||
onChange={(value) => setBirthdate(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "death_date" })}
|
||||
result={deathDate}
|
||||
onChange={(value) => setDeathDate(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "ethnicity" })}
|
||||
result={ethnicity}
|
||||
onChange={(value) => setEthnicity(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "country" })}
|
||||
result={country}
|
||||
onChange={(value) => setCountry(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "hair_color" })}
|
||||
result={hairColor}
|
||||
onChange={(value) => setHairColor(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "eye_color" })}
|
||||
result={eyeColor}
|
||||
onChange={(value) => setEyeColor(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "height" })}
|
||||
result={height}
|
||||
onChange={(value) => setHeight(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "weight" })}
|
||||
result={weight}
|
||||
onChange={(value) => setWeight(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "penis_length" })}
|
||||
result={penisLength}
|
||||
onChange={(value) => setPenisLength(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "measurements" })}
|
||||
result={measurements}
|
||||
onChange={(value) => setMeasurements(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "fake_tits" })}
|
||||
result={fakeTits}
|
||||
onChange={(value) => setFakeTits(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "career_length" })}
|
||||
result={careerLength}
|
||||
onChange={(value) => setCareerLength(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "tattoos" })}
|
||||
result={tattoos}
|
||||
onChange={(value) => setTattoos(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "piercings" })}
|
||||
result={piercings}
|
||||
onChange={(value) => setPiercings(value)}
|
||||
/>
|
||||
<ScrapedStringListRow
|
||||
title={intl.formatMessage({ id: "urls" })}
|
||||
result={urls}
|
||||
onChange={(value) => setURLs(value)}
|
||||
/>
|
||||
{renderScrapedGenderRow(
|
||||
intl.formatMessage({ id: "gender" }),
|
||||
gender,
|
||||
(value) => setGender(value)
|
||||
)}
|
||||
{renderScrapedCircumcisedRow(
|
||||
intl.formatMessage({ id: "circumcised" }),
|
||||
circumcised,
|
||||
(value) => setCircumcised(value)
|
||||
)}
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
<ScrapedImageRow
|
||||
title={intl.formatMessage({ id: "performer_image" })}
|
||||
className="performer-image"
|
||||
result={image}
|
||||
onChange={(value) => setImage(value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createValues(): MergeOptions {
|
||||
// only set the cover image if it's different from the existing cover image
|
||||
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
|
||||
|
||||
return {
|
||||
values: {
|
||||
id: dest.id,
|
||||
name: name.getNewValue(),
|
||||
disambiguation: disambiguation.getNewValue(),
|
||||
alias_list: aliases
|
||||
.getNewValue()
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
birthdate: birthdate.getNewValue(),
|
||||
death_date: deathDate.getNewValue(),
|
||||
ethnicity: ethnicity.getNewValue(),
|
||||
country: country.getNewValue(),
|
||||
hair_color: hairColor.getNewValue(),
|
||||
eye_color: eyeColor.getNewValue(),
|
||||
height_cm: height.getNewValue()
|
||||
? parseFloat(height.getNewValue()!)
|
||||
: undefined,
|
||||
weight: weight.getNewValue()
|
||||
? parseFloat(weight.getNewValue()!)
|
||||
: undefined,
|
||||
penis_length: penisLength.getNewValue()
|
||||
? parseFloat(penisLength.getNewValue()!)
|
||||
: undefined,
|
||||
measurements: measurements.getNewValue(),
|
||||
fake_tits: fakeTits.getNewValue(),
|
||||
career_length: careerLength.getNewValue(),
|
||||
tattoos: tattoos.getNewValue(),
|
||||
piercings: piercings.getNewValue(),
|
||||
urls: urls.getNewValue(),
|
||||
gender: stringToGender(gender.getNewValue()),
|
||||
circumcised: stringToCircumcised(circumcised.getNewValue()),
|
||||
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
|
||||
details: details.getNewValue(),
|
||||
image: coverImage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dialogTitle = intl.formatMessage({
|
||||
id: "actions.merge",
|
||||
});
|
||||
|
||||
const destinationLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.destination" });
|
||||
const sourceLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.source" });
|
||||
|
||||
return (
|
||||
<ScrapeDialog
|
||||
title={dialogTitle}
|
||||
existingLabel={destinationLabel}
|
||||
scrapedLabel={sourceLabel}
|
||||
renderScrapeRows={renderScrapeRows}
|
||||
onClose={(apply) => {
|
||||
if (!apply) {
|
||||
onClose();
|
||||
} else {
|
||||
onClose(createValues());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPerformerMergeModalProps {
|
||||
show: boolean;
|
||||
onClose: (mergedID?: string) => void;
|
||||
performers: GQL.SelectPerformerDataFragment[];
|
||||
}
|
||||
|
||||
export const PerformerMergeModal: React.FC<IPerformerMergeModalProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
performers,
|
||||
}) => {
|
||||
const [sourcePerformers, setSourcePerformers] = useState<
|
||||
GQL.SelectPerformerDataFragment[]
|
||||
>([]);
|
||||
const [destPerformer, setDestPerformer] = useState<
|
||||
GQL.SelectPerformerDataFragment[]
|
||||
>([]);
|
||||
|
||||
const [loadedSources, setLoadedSources] = useState<
|
||||
GQL.PerformerDataFragment[]
|
||||
>([]);
|
||||
const [loadedDest, setLoadedDest] = useState<GQL.PerformerDataFragment>();
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [secondStep, setSecondStep] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const title = intl.formatMessage({
|
||||
id: "actions.merge",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (performers.length > 0) {
|
||||
// set the first performer as the destination, others as source
|
||||
setDestPerformer([performers[0]]);
|
||||
|
||||
if (performers.length > 1) {
|
||||
setSourcePerformers(performers.slice(1));
|
||||
}
|
||||
}
|
||||
}, [performers]);
|
||||
|
||||
async function loadPerformers() {
|
||||
const performerIDs = sourcePerformers.map((s) => parseInt(s.id));
|
||||
performerIDs.push(parseInt(destPerformer[0].id));
|
||||
const query = await queryFindPerformersByID(performerIDs);
|
||||
const { performers: loadedPerformers } = query.data.findPerformers;
|
||||
|
||||
setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id));
|
||||
setLoadedSources(
|
||||
loadedPerformers.filter((s) => s.id !== destPerformer[0].id)
|
||||
);
|
||||
setSecondStep(true);
|
||||
}
|
||||
|
||||
async function onMerge(options: MergeOptions) {
|
||||
const { values } = options;
|
||||
try {
|
||||
setRunning(true);
|
||||
const result = await mutatePerformerMerge(
|
||||
destPerformer[0].id,
|
||||
sourcePerformers.map((s) => s.id),
|
||||
values
|
||||
);
|
||||
if (result.data?.performerMerge) {
|
||||
Toast.success(intl.formatMessage({ id: "toast.merged_performers" }));
|
||||
// refetch the performer
|
||||
await queryFindPerformer(destPerformer[0].id);
|
||||
onClose(destPerformer[0].id);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function canMerge() {
|
||||
return sourcePerformers.length > 0 && destPerformer.length !== 0;
|
||||
}
|
||||
|
||||
function switchPerformers() {
|
||||
if (sourcePerformers.length && destPerformer.length) {
|
||||
const newDest = sourcePerformers[0];
|
||||
setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]);
|
||||
setDestPerformer([newDest]);
|
||||
}
|
||||
}
|
||||
|
||||
if (secondStep && destPerformer.length > 0) {
|
||||
return (
|
||||
<PerformerMergeDetails
|
||||
sources={loadedSources}
|
||||
dest={loadedDest!}
|
||||
onClose={(values) => {
|
||||
setSecondStep(false);
|
||||
if (values) {
|
||||
onMerge(values);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show={show}
|
||||
header={title}
|
||||
icon={faSignInAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.next_action" }),
|
||||
onClick: () => loadPerformers(),
|
||||
}}
|
||||
disabled={!canMerge()}
|
||||
cancel={{
|
||||
variant: "secondary",
|
||||
onClick: () => onClose(),
|
||||
}}
|
||||
isRunning={running}
|
||||
>
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
<Form.Group controlId="source" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "dialogs.merge.source" }),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
onSelect={(items) => setSourcePerformers(items)}
|
||||
values={sourcePerformers}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group
|
||||
controlId="switch"
|
||||
as={Row}
|
||||
className="justify-content-center"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => switchPerformers()}
|
||||
disabled={!sourcePerformers.length || !destPerformer.length}
|
||||
title={intl.formatMessage({ id: "actions.swap" })}
|
||||
>
|
||||
<Icon className="fa-fw" icon={faExchangeAlt} />
|
||||
</Button>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="destination" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({
|
||||
id: "dialogs.merge.destination",
|
||||
}),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<PerformerSelect
|
||||
onSelect={(items) => setDestPerformer(items)}
|
||||
values={destPerformer}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
|
@ -730,6 +730,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
|
|||
sources={loadedSources}
|
||||
dest={loadedDest!}
|
||||
onClose={(values) => {
|
||||
setSecondStep(false);
|
||||
if (values) {
|
||||
onMerge(values);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -343,6 +343,14 @@ export const queryFindPerformers = (filter: ListFilterModel) =>
|
|||
},
|
||||
});
|
||||
|
||||
export const queryFindPerformersByID = (performerIDs: number[]) =>
|
||||
client.query<GQL.FindPerformersQuery>({
|
||||
query: GQL.FindPerformersDocument,
|
||||
variables: {
|
||||
performer_ids: performerIDs,
|
||||
},
|
||||
});
|
||||
|
||||
export const queryFindPerformersByIDForSelect = (performerIDs: string[]) =>
|
||||
client.query<GQL.FindPerformersForSelectQuery>({
|
||||
query: GQL.FindPerformersForSelectDocument,
|
||||
|
|
@ -1805,7 +1813,6 @@ export const usePerformerDestroy = () =>
|
|||
});
|
||||
evictQueries(cache, [
|
||||
...performerMutationImpactedQueries,
|
||||
GQL.FindPerformersDocument, // appears with
|
||||
GQL.FindGroupsDocument, // filter by performers
|
||||
GQL.FindSceneMarkersDocument, // filter by performers
|
||||
]);
|
||||
|
|
@ -1845,13 +1852,44 @@ export const usePerformersDestroy = (
|
|||
});
|
||||
evictQueries(cache, [
|
||||
...performerMutationImpactedQueries,
|
||||
GQL.FindPerformersDocument, // appears with
|
||||
GQL.FindGroupsDocument, // filter by performers
|
||||
GQL.FindSceneMarkersDocument, // filter by performers
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
export const mutatePerformerMerge = (
|
||||
destination: string,
|
||||
source: string[],
|
||||
values: GQL.PerformerUpdateInput
|
||||
) =>
|
||||
client.mutate<GQL.PerformerMergeMutation>({
|
||||
mutation: GQL.PerformerMergeDocument,
|
||||
variables: {
|
||||
input: {
|
||||
source,
|
||||
destination,
|
||||
values,
|
||||
},
|
||||
},
|
||||
update(cache, result) {
|
||||
if (!result.data?.performerMerge) return;
|
||||
|
||||
for (const id of source) {
|
||||
const obj = { __typename: "Performer", id };
|
||||
deleteObject(cache, obj, GQL.FindPerformerDocument);
|
||||
}
|
||||
|
||||
evictTypeFields(cache, performerMutationImpactedTypeFields);
|
||||
evictQueries(cache, [
|
||||
...performerMutationImpactedQueries,
|
||||
GQL.FindGroupsDocument, // filter by performers
|
||||
GQL.FindSceneMarkersDocument, // filter by performers
|
||||
GQL.StatsDocument, // performer count
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const studioMutationImpactedTypeFields = {
|
||||
Studio: ["child_studios"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1515,6 +1515,7 @@
|
|||
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||
"generating_screenshot": "Generating screenshot…",
|
||||
"image_index_too_large": "Error: Image index is larger than the number of images in the Gallery",
|
||||
"merged_performers": "Merged performers",
|
||||
"merged_scenes": "Merged scenes",
|
||||
"merged_tags": "Merged tags",
|
||||
"reassign_past_tense": "File reassigned",
|
||||
|
|
|
|||
Loading…
Reference in a new issue