mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Merge branch 'develop' into issue-5298
This commit is contained in:
commit
6cb579eeba
12 changed files with 129 additions and 21 deletions
|
|
@ -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!]
|
||||
}
|
||||
|
|
|
|||
12
internal/api/custom_fields.go
Normal file
12
internal/api/custom_fields.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue