Merge branch 'develop' into issue-5298

This commit is contained in:
WithoutPants 2025-12-04 07:44:33 +11:00 committed by GitHub
commit 6cb579eeba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 129 additions and 21 deletions

View file

@ -344,4 +344,6 @@ input CustomFieldsInput {
full: Map full: Map
"If populated, only the keys in this map will be updated" "If populated, only the keys in this map will be updated"
partial: Map partial: Map
"Remove any keys in this list"
remove: [String!]
} }

View file

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

View file

@ -297,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
updatedPerformer.CustomFields = input.CustomFields updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
// convert json.Numbers to int/float
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") 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) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
if input.CustomFields != nil {
updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
ret := []*models.Performer{} ret := []*models.Performer{}
// Start the transaction and save the performers // Start the transaction and save the performers

View file

@ -88,7 +88,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
performer = mergedPerformer performer = mergedPerformer
} }
} }
case t.performer != nil: case t.performer != nil: // tagging or updating existing performer
var remoteID string var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Performer qb := r.Performer
@ -123,6 +123,9 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex
performer = mergedPerformer 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 != "" { if remoteID != "" {
studio, err = client.FindStudio(ctx, remoteID) studio, err = client.FindStudio(ctx, remoteID)
} else {
// find by studio name instead
studio, err = client.FindStudio(ctx, t.studio.Name)
} }
} }

View file

@ -9,6 +9,8 @@ type CustomFieldsInput struct {
Full map[string]interface{} `json:"full"` Full map[string]interface{} `json:"full"`
// If populated, only the keys in this map will be updated // If populated, only the keys in this map will be updated
Partial map[string]interface{} `json:"partial"` Partial map[string]interface{} `json:"partial"`
// Remove any keys in this list
Remove []string `json:"remove"`
} }
type CustomFieldsReader interface { type CustomFieldsReader interface {

View file

@ -32,7 +32,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
ret := NewStudio() ret := NewStudio()
ret.Name = strings.TrimSpace(s.Name) ret.Name = strings.TrimSpace(s.Name)
if s.RemoteSiteID != nil && endpoint != "" { if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{ ret.StashIDs = NewRelatedStashIDs([]StashID{
{ {
Endpoint: endpoint, 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{ ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs, StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet, 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{ ret.StashIDs = NewRelatedStashIDs([]StashID{
{ {
Endpoint: endpoint, 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{ ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs, StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet, Mode: RelationshipUpdateModeSet,
@ -464,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
ret := NewTag() ret := NewTag()
ret.Name = t.Name ret.Name = t.Name
if t.RemoteSiteID != nil && endpoint != "" { if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{ ret.StashIDs = NewRelatedStashIDs([]StashID{
{ {
Endpoint: endpoint, Endpoint: endpoint,

View file

@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values
case values.Partial != nil: case values.Partial != nil:
partial = true partial = true
valMap = values.Partial 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 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 // ensure that custom field names are valid
// no leading or trailing whitespace, no empty strings // no leading or trailing whitespace, no empty strings
for k := range values { 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 return nil
} }
@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values
return nil 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) { 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)) q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id))

View file

@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) {
}), }),
false, false,
}, },
{
"valid remove",
models.CustomFieldsInput{
Remove: []string{"real"},
},
func() map[string]interface{} {
m := getPerformerCustomFields(performerIdx)
delete(m, "real")
return m
}(),
false,
},
{ {
"leading space full", "leading space full",
models.CustomFieldsInput{ models.CustomFieldsInput{
@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) {
nil, nil,
true, 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 // use performer custom fields store
store := db.Performer store := db.Performer
id := performerIDs[performerIdx] id := performerIDs[performerIdx]
assert := assert.New(t)
for _, tt := range tests { for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
err := store.SetCustomFields(ctx, id, tt.input) err := store.SetCustomFields(ctx, id, tt.input)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr)

View file

@ -125,8 +125,8 @@ func translateGender(gender *graphql.GenderEnum) *string {
return nil return nil
} }
func formatMeasurements(m graphql.MeasurementsFragment) *string { func formatMeasurements(m *graphql.MeasurementsFragment) *string {
if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { 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) ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip)
return &ret return &ret
} }
@ -209,7 +209,7 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc
Name: &p.Name, Name: &p.Name,
Disambiguation: p.Disambiguation, Disambiguation: p.Disambiguation,
Country: p.Country, Country: p.Country,
Measurements: formatMeasurements(*p.Measurements), Measurements: formatMeasurements(p.Measurements),
CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear),
Tattoos: formatBodyModifications(p.Tattoos), Tattoos: formatBodyModifications(p.Tattoos),
Piercings: formatBodyModifications(p.Piercings), Piercings: formatBodyModifications(p.Piercings),

View file

@ -8,6 +8,7 @@ import { Icon } from "./Icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames"; import cx from "classnames";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
import { TruncatedText } from "./TruncatedText";
const maxFieldNameLength = 64; const maxFieldNameLength = 64;
@ -47,7 +48,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({
id={id} id={id}
label={field} label={field}
labelTitle={field} labelTitle={field}
value={valueStr} value={<TruncatedText lineCount={5} text={<>{valueStr}</>} />}
fullWidth={true} fullWidth={true}
showEmpty showEmpty
/> />

View file

@ -712,6 +712,10 @@ button.btn.favorite-button {
.custom-fields { .custom-fields {
width: 100%; width: 100%;
.detail-item {
max-width: 100%;
}
} }
.custom-fields .detail-item .detail-item-title { .custom-fields .detail-item .detail-item-title {
@ -721,6 +725,14 @@ button.btn.favorite-button {
white-space: nowrap; white-space: nowrap;
} }
.custom-fields .detail-item .detail-item-value {
word-break: break-word;
.TruncatedText {
white-space: pre-line;
}
}
.custom-fields-input > .collapse-button { .custom-fields-input > .collapse-button {
font-weight: 700; font-weight: 700;
} }

View file

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