diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 923c25b4c..c01858f64 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -344,4 +344,6 @@ input CustomFieldsInput { full: Map "If populated, only the keys in this map will be updated" partial: Map + "Remove any keys in this list" + remove: [String!] } diff --git a/internal/api/custom_fields.go b/internal/api/custom_fields.go new file mode 100644 index 000000000..5eaa6f67a --- /dev/null +++ b/internal/api/custom_fields.go @@ -0,0 +1,12 @@ +package api + +import "github.com/stashapp/stash/pkg/models" + +func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput { + ret := input + // convert json.Numbers to int/float + ret.Full = convertMapJSONNumbers(ret.Full) + ret.Partial = convertMapJSONNumbers(ret.Partial) + + return ret +} diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 15fb5056a..c54e3ca93 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -297,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedPerformer.CustomFields = input.CustomFields - // convert json.Numbers to int/float - updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full) - updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial) + updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) var imageData []byte imageIncluded := translator.hasField("image") @@ -417,6 +414,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Performer{} // Start the transaction and save the performers diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index d7d987a6d..37859ba61 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -88,7 +88,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } - case t.performer != nil: + case t.performer != nil: // tagging or updating existing performer var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Performer @@ -123,6 +123,9 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } + } else { + // find by performer name instead + performer, err = client.FindPerformerByName(ctx, t.performer.Name) } } @@ -328,6 +331,9 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m if remoteID != "" { studio, err = client.FindStudio(ctx, remoteID) + } else { + // find by studio name instead + studio, err = client.FindStudio(ctx, t.studio.Name) } } diff --git a/pkg/models/custom_fields.go b/pkg/models/custom_fields.go index 977c2fe89..5c3acd18b 100644 --- a/pkg/models/custom_fields.go +++ b/pkg/models/custom_fields.go @@ -9,6 +9,8 @@ type CustomFieldsInput struct { Full map[string]interface{} `json:"full"` // If populated, only the keys in this map will be updated Partial map[string]interface{} `json:"partial"` + // Remove any keys in this list + Remove []string `json:"remove"` } type CustomFieldsReader interface { diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 570f6034b..4254a9876 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,7 +32,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu ret := NewStudio() ret.Name = strings.TrimSpace(s.Name) - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -141,7 +141,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin } } - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -306,7 +306,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -435,7 +435,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -464,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { ret := NewTag() ret.Name = t.Name - if t.RemoteSiteID != nil && endpoint != "" { + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index bac6ae5e1..63f85b250 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values case values.Partial != nil: partial = true valMap = values.Partial - default: - return nil } - if err := s.validateCustomFields(valMap); err != nil { + if valMap != nil { + if err := s.validateCustomFields(valMap, values.Remove); err != nil { + return err + } + + if err := s.setCustomFields(ctx, id, valMap, partial); err != nil { + return err + } + } + + if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil { return err } - return s.setCustomFields(ctx, id, valMap, partial) + return nil } -func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error { +func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error { + // if values is nil, nothing to validate + if values == nil { + return nil + } + // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings for k := range values { @@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) } } + // ensure delete keys are not also in values + for _, k := range deleteKeys { + if _, ok := values[k]; ok { + return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k) + } + } + return nil } @@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values return nil } +func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error { + if len(keys) == 0 { + return nil + } + + q := dialect.Delete(s.table). + Where(s.fk.Eq(id)). + Where(goqu.I("field").In(keys)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("deleting custom fields: %w", err) + } + + return nil +} + func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id)) diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ce5c77487..8ee154aec 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) { }), false, }, + { + "valid remove", + models.CustomFieldsInput{ + Remove: []string{"real"}, + }, + func() map[string]interface{} { + m := getPerformerCustomFields(performerIdx) + delete(m, "real") + return m + }(), + false, + }, { "leading space full", models.CustomFieldsInput{ @@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) { nil, true, }, + { + "invalid remove full", + models.CustomFieldsInput{ + Full: map[string]interface{}{ + "key": "value", + }, + Remove: []string{"key"}, + }, + nil, + true, + }, + { + "invalid remove partial", + models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "real": float64(4.56), + }, + Remove: []string{"real"}, + }, + nil, + true, + }, } // use performer custom fields store store := db.Performer id := performerIDs[performerIdx] - assert := assert.New(t) - for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + err := store.SetCustomFields(ctx, id, tt.input) if (err != nil) != tt.wantErr { t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr) diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 56d7b109e..38824eba1 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -125,8 +125,8 @@ func translateGender(gender *graphql.GenderEnum) *string { return nil } -func formatMeasurements(m graphql.MeasurementsFragment) *string { - if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { +func formatMeasurements(m *graphql.MeasurementsFragment) *string { + if m != nil && m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip) return &ret } @@ -209,7 +209,7 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc Name: &p.Name, Disambiguation: p.Disambiguation, Country: p.Country, - Measurements: formatMeasurements(*p.Measurements), + Measurements: formatMeasurements(p.Measurements), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index a522961a8..e7355df66 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -8,6 +8,7 @@ import { Icon } from "./Icon"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { PatchComponent } from "src/patch"; +import { TruncatedText } from "./TruncatedText"; const maxFieldNameLength = 64; @@ -47,7 +48,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ id={id} label={field} labelTitle={field} - value={valueStr} + value={{valueStr}} />} fullWidth={true} showEmpty /> diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f7ad76e9d..55dff9d0f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -712,6 +712,10 @@ button.btn.favorite-button { .custom-fields { width: 100%; + + .detail-item { + max-width: 100%; + } } .custom-fields .detail-item .detail-item-title { @@ -721,6 +725,14 @@ button.btn.favorite-button { white-space: nowrap; } +.custom-fields .detail-item .detail-item-value { + word-break: break-word; + + .TruncatedText { + white-space: pre-line; + } +} + .custom-fields-input > .collapse-button { font-weight: 700; } diff --git a/ui/v2.5/src/docs/en/Manual/Identify.md b/ui/v2.5/src/docs/en/Manual/Identify.md index 8d6cc9325..724a392a3 100644 --- a/ui/v2.5/src/docs/en/Manual/Identify.md +++ b/ui/v2.5/src/docs/en/Manual/Identify.md @@ -2,6 +2,8 @@ The Identify task iterates through your Scenes and attempts to identify them using a selection of scraping sources. If a result is found in a source, the Scene is updated, and no further sources are checked for that scene. +This task is part of the advanced settings mode. + ## Rules - The task accepts one or more scraper sources, including stash-box instances and scene scrapers that support scraping via Scene Fragment. The order of the sources can be rearranged.