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
|
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!]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue