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
"If populated, only the keys in this map will be updated"
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)
}
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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={<TruncatedText lineCount={5} text={<>{valueStr}</>} />}
fullWidth={true}
showEmpty
/>

View file

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

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