From 776c7e6c35ec96f47ab24ba5f5019f0d3abf8f1b Mon Sep 17 00:00:00 2001 From: departure18 <92104199+departure18@users.noreply.github.com> Date: Wed, 24 May 2023 04:19:35 +0100 Subject: [PATCH] Add penis length and circumcision stats to performers. (#3627) * Add penis length stat to performers. * Modified the UI to display and edit the stat. * Added the ability to filter floats to allow filtering by penis length. * Add circumcision stat to performer. * Refactor enum filtering * Change boolean filter to radio buttons * Return null for empty enum values --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/performer-slim.graphql | 2 + graphql/documents/data/performer.graphql | 2 + graphql/documents/data/scrapers.graphql | 4 + graphql/schema/types/filters.graphql | 15 ++ graphql/schema/types/performer.graphql | 13 ++ .../schema/types/scraped-performer.graphql | 4 + internal/api/images.go | 9 +- internal/api/resolver_mutation_performer.go | 28 +++- internal/identify/performer.go | 13 +- internal/identify/performer_test.go | 6 +- internal/manager/task_stash_box_tag.go | 6 +- pkg/models/filter.go | 14 ++ pkg/models/jsonschema/performer.go | 2 + pkg/models/model_performer.go | 44 ++--- pkg/models/model_scraped_item.go | 2 + pkg/models/performer.go | 50 ++++++ pkg/performer/export.go | 13 +- pkg/performer/export_test.go | 20 ++- pkg/performer/import.go | 15 +- pkg/scraper/autotag.go | 2 +- pkg/scraper/performer.go | 2 + pkg/scraper/stash.go | 2 + pkg/scraper/stashbox/stash_box.go | 2 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/filter.go | 32 ++++ pkg/sqlite/migrations/46_penis_stats.up.sql | 2 + pkg/sqlite/performer.go | 31 +++- pkg/sqlite/performer_test.go | 150 ++++++++++++++++-- pkg/sqlite/record.go | 11 +- pkg/sqlite/setup_test.go | 25 +++ pkg/sqlite/sql.go | 52 ++++-- pkg/sqlite/values.go | 9 ++ pkg/utils/strings.go | 10 ++ .../src/components/List/CriterionEditor.tsx | 23 ++- .../components/List/Filters/BooleanFilter.tsx | 4 +- .../components/List/Filters/OptionFilter.tsx | 85 ++++++++++ .../components/List/Filters/OptionsFilter.tsx | 45 ------ .../List/Filters/OptionsListFilter.tsx | 45 ------ .../Performers/EditPerformersDialog.tsx | 54 +++++++ .../PerformerDetailsPanel.tsx | 56 ++++++- .../PerformerDetails/PerformerEditPanel.tsx | 64 ++++++++ .../PerformerScrapeDialog.tsx | 101 ++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 28 ++-- ui/v2.5/src/core/StashService.ts | 5 + ui/v2.5/src/locales/en-GB.json | 8 + .../list-filter/criteria/circumcised.ts | 36 +++++ .../models/list-filter/criteria/criterion.ts | 19 +++ .../models/list-filter/criteria/factory.ts | 5 + ui/v2.5/src/models/list-filter/performers.ts | 4 + ui/v2.5/src/models/list-filter/types.ts | 2 + ui/v2.5/src/utils/circumcised.ts | 51 ++++++ ui/v2.5/src/utils/units.ts | 6 + 52 files changed, 1051 insertions(+), 184 deletions(-) create mode 100644 pkg/sqlite/migrations/46_penis_stats.up.sql create mode 100644 ui/v2.5/src/components/List/Filters/OptionFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/circumcised.ts create mode 100644 ui/v2.5/src/utils/circumcised.ts diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 4bac5d90b..65019b98b 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -16,6 +16,8 @@ fragment SlimPerformerData on Performer { eye_color height_cm fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index ed469f01e..c89ce1e13 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -14,6 +14,8 @@ fragment PerformerData on Performer { height_cm measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 8d02b3362..1d4553a97 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -13,6 +13,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings @@ -43,6 +45,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a635eaf51..0b18cbfee 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -76,6 +76,10 @@ input PerformerFilterType { measurements: StringCriterionInput """Filter by fake tits value""" fake_tits: StringCriterionInput + """Filter by penis length value""" + penis_length: FloatCriterionInput + """Filter by ciricumcision""" + circumcised: CircumcisionCriterionInput """Filter by career length""" career_length: StringCriterionInput """Filter by tattoos""" @@ -505,6 +509,12 @@ input IntCriterionInput { modifier: CriterionModifier! } +input FloatCriterionInput { + value: Float! + value2: Float + modifier: CriterionModifier! +} + input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! @@ -514,6 +524,11 @@ input GenderCriterionInput { value: GenderEnum modifier: CriterionModifier! } + +input CircumcisionCriterionInput { + value: [CircumisedEnum!] + modifier: CriterionModifier! +} input HierarchicalMultiCriterionInput { value: [ID!] diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 401f3b7c6..6cbe6ed32 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -6,6 +6,11 @@ enum GenderEnum { INTERSEX NON_BINARY } + +enum CircumisedEnum { + CUT + UNCUT +} type Performer { id: ID! @@ -24,6 +29,8 @@ type Performer { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -69,6 +76,8 @@ input PerformerCreateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -107,6 +116,8 @@ input PerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -150,6 +161,8 @@ input BulkPerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 518e5abca..a23b04fed 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -15,6 +15,8 @@ type ScrapedPerformer { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String @@ -48,6 +50,8 @@ input ScrapedPerformerInput { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String diff --git a/internal/api/images.go b/internal/api/images.go index ddcaee629..7ddbbfc10 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -87,7 +87,7 @@ func initialiseCustomImages() { } } -func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, customPath string) ([]byte, error) { +func getRandomPerformerImageUsingName(name string, gender *models.GenderEnum, customPath string) ([]byte, error) { var box *imageBox // If we have a custom path, we should return a new box in the given path. @@ -95,8 +95,13 @@ func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, cus box = performerBoxCustom } + var g models.GenderEnum + if gender != nil { + g = *gender + } + if box == nil { - switch gender { + switch g { case models.GenderEnumFemale: box = performerBox case models.GenderEnumMale: diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 5b9304ba3..2f3e9e01b 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -67,7 +67,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC newPerformer.URL = *input.URL } if input.Gender != nil { - newPerformer.Gender = *input.Gender + newPerformer.Gender = input.Gender } if input.Birthdate != nil { d := models.NewDate(*input.Birthdate) @@ -98,6 +98,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC if input.FakeTits != nil { newPerformer.FakeTits = *input.FakeTits } + if input.PenisLength != nil { + newPerformer.PenisLength = input.PenisLength + } + if input.Circumcised != nil { + newPerformer.Circumcised = input.Circumcised + } if input.CareerLength != nil { newPerformer.CareerLength = *input.CareerLength } @@ -222,6 +228,16 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") @@ -339,6 +355,16 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/internal/identify/performer.go b/internal/identify/performer.go index a78a0ce6c..cb16f2a83 100644 --- a/internal/identify/performer.go +++ b/internal/identify/performer.go @@ -65,7 +65,8 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe ret.DeathDate = &d } if performer.Gender != nil { - ret.Gender = models.GenderEnum(*performer.Gender) + v := models.GenderEnum(*performer.Gender) + ret.Gender = &v } if performer.Ethnicity != nil { ret.Ethnicity = *performer.Ethnicity @@ -97,6 +98,16 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe if performer.FakeTits != nil { ret.FakeTits = *performer.FakeTits } + if performer.PenisLength != nil { + h, err := strconv.ParseFloat(*performer.PenisLength, 64) + if err == nil { + ret.PenisLength = &h + } + } + if performer.Circumcised != nil { + v := models.CircumisedEnum(*performer.Circumcised) + ret.Circumcised = &v + } if performer.CareerLength != nil { ret.CareerLength = *performer.CareerLength } diff --git a/internal/identify/performer_test.go b/internal/identify/performer_test.go index 0a78ea173..9ba1018c7 100644 --- a/internal/identify/performer_test.go +++ b/internal/identify/performer_test.go @@ -228,6 +228,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { return &d } + genderPtr := func(g models.GenderEnum) *models.GenderEnum { + return &g + } + tests := []struct { name string performer *models.ScrapedPerformer @@ -259,7 +263,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Name: name, Birthdate: dateToDatePtr(models.NewDate(*nextVal())), DeathDate: dateToDatePtr(models.NewDate(*nextVal())), - Gender: models.GenderEnum(*nextVal()), + Gender: genderPtr(models.GenderEnum(*nextVal())), Ethnicity: *nextVal(), Country: *nextVal(), EyeColor: *nextVal(), diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index e927a0335..dd31b4899 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -131,7 +131,6 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { EyeColor: getString(performer.EyeColor), HairColor: getString(performer.HairColor), FakeTits: getString(performer.FakeTits), - Gender: models.GenderEnum(getString(performer.Gender)), Height: getIntPtr(performer.Height), Weight: getIntPtr(performer.Weight), Instagram: getString(performer.Instagram), @@ -150,6 +149,11 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { UpdatedAt: currentTime, } + if performer.Gender != nil { + v := models.GenderEnum(getString(performer.Gender)) + newPerformer.Gender = &v + } + err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { r := instance.Repository err := r.Performer.Create(ctx, &newPerformer) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 47e93f237..42cff1118 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -109,6 +109,20 @@ func (i IntCriterionInput) ValidModifier() bool { return false } +type FloatCriterionInput struct { + Value float64 `json:"value"` + Value2 *float64 `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} + +func (i FloatCriterionInput) ValidModifier() bool { + switch i.Modifier { + case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: + return true + } + return false +} + type ResolutionCriterionInput struct { Value ResolutionEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index c0996a1a5..248cf9557 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -48,6 +48,8 @@ type Performer struct { Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` FakeTits string `json:"fake_tits,omitempty"` + PenisLength float64 `json:"penis_length,omitempty"` + Circumcised string `json:"circumcised,omitempty"` CareerLength string `json:"career_length,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index fd52a7674..134d46783 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -6,26 +6,28 @@ import ( ) type Performer struct { - ID int `json:"id"` - Name string `json:"name"` - Disambiguation string `json:"disambiguation"` - Gender GenderEnum `json:"gender"` - URL string `json:"url"` - Twitter string `json:"twitter"` - Instagram string `json:"instagram"` - Birthdate *Date `json:"birthdate"` - Ethnicity string `json:"ethnicity"` - Country string `json:"country"` - EyeColor string `json:"eye_color"` - Height *int `json:"height"` - Measurements string `json:"measurements"` - FakeTits string `json:"fake_tits"` - CareerLength string `json:"career_length"` - Tattoos string `json:"tattoos"` - Piercings string `json:"piercings"` - Favorite bool `json:"favorite"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Disambiguation string `json:"disambiguation"` + Gender *GenderEnum `json:"gender"` + URL string `json:"url"` + Twitter string `json:"twitter"` + Instagram string `json:"instagram"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumisedEnum `json:"circumcised"` + CareerLength string `json:"career_length"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` @@ -90,6 +92,8 @@ type PerformerPartial struct { Height OptionalInt Measurements OptionalString FakeTits OptionalString + PenisLength OptionalFloat64 + Circumcised OptionalString CareerLength OptionalString Tattoos OptionalString Piercings OptionalString diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index fa25bcb7e..9d497b043 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,6 +32,8 @@ type ScrapedPerformer struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/models/performer.go b/pkg/models/performer.go index aa6ea3af6..23b70b0da 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -61,6 +61,52 @@ type GenderCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type CircumisedEnum string + +const ( + CircumisedEnumCut CircumisedEnum = "CUT" + CircumisedEnumUncut CircumisedEnum = "UNCUT" +) + +var AllCircumcisionEnum = []CircumisedEnum{ + CircumisedEnumCut, + CircumisedEnumUncut, +} + +func (e CircumisedEnum) IsValid() bool { + switch e { + case CircumisedEnumCut, CircumisedEnumUncut: + return true + } + return false +} + +func (e CircumisedEnum) String() string { + return string(e) +} + +func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CircumisedEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CircumisedEnum", str) + } + return nil +} + +func (e CircumisedEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type CircumcisionCriterionInput struct { + Value []CircumisedEnum `json:"value"` + Modifier CriterionModifier `json:"modifier"` +} + type PerformerFilterType struct { And *PerformerFilterType `json:"AND"` Or *PerformerFilterType `json:"OR"` @@ -88,6 +134,10 @@ type PerformerFilterType struct { Measurements *StringCriterionInput `json:"measurements"` // Filter by fake tits value FakeTits *StringCriterionInput `json:"fake_tits"` + // Filter by penis length value + PenisLength *FloatCriterionInput `json:"penis_length"` + // Filter by circumcision + Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length CareerLength *StringCriterionInput `json:"career_length"` // Filter by tattoos diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 4b46fd901..9aec8b34e 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -23,7 +23,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, - Gender: performer.Gender.String(), URL: performer.URL, Ethnicity: performer.Ethnicity, Country: performer.Country, @@ -43,6 +42,14 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } + if performer.Gender != nil { + newPerformerJSON.Gender = performer.Gender.String() + } + + if performer.Circumcised != nil { + newPerformerJSON.Circumcised = performer.Circumcised.String() + } + if performer.Birthdate != nil { newPerformerJSON.Birthdate = performer.Birthdate.String() } @@ -61,6 +68,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.Weight = *performer.Weight } + if performer.PenisLength != nil { + newPerformerJSON.PenisLength = *performer.PenisLength + } + if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index f65693e3f..c5965404a 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -29,7 +29,6 @@ const ( ethnicity = "ethnicity" eyeColor = "eyeColor" fakeTits = "fakeTits" - gender = "gender" instagram = "instagram" measurements = "measurements" piercings = "piercings" @@ -42,10 +41,15 @@ const ( ) var ( - aliases = []string{"alias1", "alias2"} - rating = 5 - height = 123 - weight = 60 + genderEnum = models.GenderEnumFemale + gender = genderEnum.String() + aliases = []string{"alias1", "alias2"} + rating = 5 + height = 123 + weight = 60 + penisLength = 1.23 + circumcisedEnum = models.CircumisedEnumCut + circumcised = circumcisedEnum.String() ) var imageBytes = []byte("imageBytes") @@ -81,8 +85,10 @@ func createFullPerformer(id int, name string) *models.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcisedEnum, Favorite: true, - Gender: gender, + Gender: &genderEnum, Height: &height, Instagram: instagram, Measurements: measurements, @@ -125,6 +131,8 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: penisLength, + Circumcised: circumcised, Favorite: true, Gender: gender, Height: strconv.Itoa(height), diff --git a/pkg/performer/import.go b/pkg/performer/import.go index beebab35d..4ca27ce55 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -189,7 +189,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, - Gender: models.GenderEnum(performerJSON.Gender), URL: performerJSON.URL, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, @@ -213,6 +212,16 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } + if performerJSON.Gender != "" { + v := models.GenderEnum(performerJSON.Gender) + newPerformer.Gender = &v + } + + if performerJSON.Circumcised != "" { + v := models.CircumisedEnum(performerJSON.Circumcised) + newPerformer.Circumcised = &v + } + if performerJSON.Birthdate != "" { d, err := utils.ParseDateStringAsTime(performerJSON.Birthdate) if err == nil { @@ -237,6 +246,10 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer.Weight = &performerJSON.Weight } + if performerJSON.PenisLength != 0 { + newPerformer.PenisLength = &performerJSON.PenisLength + } + if performerJSON.Height != "" { h, err := strconv.Atoi(performerJSON.Height) if err == nil { diff --git a/pkg/scraper/autotag.go b/pkg/scraper/autotag.go index 53aedc749..786cd024d 100644 --- a/pkg/scraper/autotag.go +++ b/pkg/scraper/autotag.go @@ -41,7 +41,7 @@ func autotagMatchPerformers(ctx context.Context, path string, performerReader ma Name: &pp.Name, StoredID: &id, } - if pp.Gender.IsValid() { + if pp.Gender != nil && pp.Gender.IsValid() { v := pp.Gender.String() sp.Gender = &v } diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 48f6ce318..269368823 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -16,6 +16,8 @@ type ScrapedPerformerInput struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 9267bad0c..652a9de0a 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -69,6 +69,8 @@ type scrapedPerformerStash struct { Height *string `graphql:"height" json:"height"` Measurements *string `graphql:"measurements" json:"measurements"` FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + PenisLength *string `graphql:"penis_length" json:"penis_length"` + Circumcised *string `graphql:"circumcised" json:"circumcised"` CareerLength *string `graphql:"career_length" json:"career_length"` Tattoos *string `graphql:"tattoos" json:"tattoos"` Piercings *string `graphql:"piercings" json:"piercings"` diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index b8eadfd1b..713265e7c 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1009,7 +1009,7 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf if performer.FakeTits != "" { draft.BreastType = &performer.FakeTits } - if performer.Gender.IsValid() { + if performer.Gender != nil && performer.Gender.IsValid() { v := performer.Gender.String() draft.Gender = &v } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index d8e8b5e0d..c18b323ee 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 45 +var appSchemaVersion uint = 46 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 057fec179..d0c74772d 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -426,6 +426,29 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite } } +func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes, models.CriterionModifierEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) + } + case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) + } + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } +} + func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { @@ -525,6 +548,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f } } +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getFloatCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { diff --git a/pkg/sqlite/migrations/46_penis_stats.up.sql b/pkg/sqlite/migrations/46_penis_stats.up.sql new file mode 100644 index 000000000..2e9e31654 --- /dev/null +++ b/pkg/sqlite/migrations/46_penis_stats.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `performers` ADD COLUMN `penis_length` float; +ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10]; \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a197b2ce5..7468db8be 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -42,6 +42,8 @@ type performerRow struct { Height null.Int `db:"height"` Measurements zero.String `db:"measurements"` FakeTits zero.String `db:"fake_tits"` + PenisLength null.Float `db:"penis_length"` + Circumcised zero.String `db:"circumcised"` CareerLength zero.String `db:"career_length"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` @@ -64,7 +66,7 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID r.Name = o.Name r.Disambigation = zero.StringFrom(o.Disambiguation) - if o.Gender.IsValid() { + if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } r.URL = zero.StringFrom(o.URL) @@ -79,6 +81,10 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.Height = intFromPtr(o.Height) r.Measurements = zero.StringFrom(o.Measurements) r.FakeTits = zero.StringFrom(o.FakeTits) + r.PenisLength = null.FloatFromPtr(o.PenisLength) + if o.Circumcised != nil && o.Circumcised.IsValid() { + r.Circumcised = zero.StringFrom(o.Circumcised.String()) + } r.CareerLength = zero.StringFrom(o.CareerLength) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) @@ -100,7 +106,6 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name, Disambiguation: r.Disambigation.String, - Gender: models.GenderEnum(r.Gender.String), URL: r.URL.String, Twitter: r.Twitter.String, Instagram: r.Instagram.String, @@ -111,6 +116,7 @@ func (r *performerRow) resolve() *models.Performer { Height: nullIntPtr(r.Height), Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, + PenisLength: nullFloatPtr(r.PenisLength), CareerLength: r.CareerLength.String, Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, @@ -126,6 +132,16 @@ func (r *performerRow) resolve() *models.Performer { IgnoreAutoTag: r.IgnoreAutoTag, } + if r.Gender.ValueOrZero() != "" { + v := models.GenderEnum(r.Gender.String) + ret.Gender = &v + } + + if r.Circumcised.ValueOrZero() != "" { + v := models.CircumisedEnum(r.Circumcised.String) + ret.Circumcised = &v + } + return ret } @@ -147,6 +163,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullInt("height", o.Height) r.setNullString("measurements", o.Measurements) r.setNullString("fake_tits", o.FakeTits) + r.setNullFloat64("penis_length", o.PenisLength) + r.setNullString("circumcised", o.Circumcised) r.setNullString("career_length", o.CareerLength) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) @@ -597,6 +615,15 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements")) query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) + query.handleCriterion(ctx, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil)) + + query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if circumcised := filter.Circumcised; circumcised != nil { + v := utils.StringerSliceToStringSlice(circumcised.Value) + enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) + } + })) + query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 2b24d6455..a874f3967 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -52,6 +52,8 @@ func Test_PerformerStore_Create(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -81,7 +83,7 @@ func Test_PerformerStore_Create(t *testing.T) { models.Performer{ Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -92,6 +94,8 @@ func Test_PerformerStore_Create(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -196,6 +200,8 @@ func Test_PerformerStore_Update(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -226,7 +232,7 @@ func Test_PerformerStore_Update(t *testing.T) { ID: performerIDs[performerIdxWithGallery], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -237,6 +243,8 @@ func Test_PerformerStore_Update(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -327,6 +335,7 @@ func clearPerformerPartial() models.PerformerPartial { nullString := models.OptionalString{Set: true, Null: true} nullDate := models.OptionalDate{Set: true, Null: true} nullInt := models.OptionalInt{Set: true, Null: true} + nullFloat := models.OptionalFloat64{Set: true, Null: true} // leave mandatory fields return models.PerformerPartial{ @@ -342,6 +351,8 @@ func clearPerformerPartial() models.PerformerPartial { Height: nullInt, Measurements: nullString, FakeTits: nullString, + PenisLength: nullFloat, + Circumcised: nullString, CareerLength: nullString, Tattoos: nullString, Piercings: nullString, @@ -372,6 +383,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { height = 143 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -415,6 +428,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: models.NewOptionalInt(height), Measurements: models.NewOptionalString(measurements), FakeTits: models.NewOptionalString(fakeTits), + PenisLength: models.NewOptionalFloat64(penisLength), + Circumcised: models.NewOptionalString(circumcised.String()), CareerLength: models.NewOptionalString(careerLength), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), @@ -453,7 +468,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { ID: performerIDs[performerIdxWithDupName], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -464,6 +479,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -957,16 +974,30 @@ func TestPerformerQuery(t *testing.T) { false, }, { - "alias", + "circumcised (cut)", nil, &models.PerformerFilterType{ - Aliases: &models.StringCriterionInput{ - Value: getPerformerStringValue(performerIdxWithGallery, "alias"), - Modifier: models.CriterionModifierEquals, + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierIncludes, }, }, - []int{performerIdxWithGallery}, - []int{performerIdxWithScene}, + []int{performerIdx1WithScene}, + []int{performerIdxWithScene, performerIdx2WithScene}, + false, + }, + { + "circumcised (excludes cut)", + nil, + &models.PerformerFilterType{ + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{performerIdx2WithScene}, + // performerIdxWithScene has null value + []int{performerIdx1WithScene, performerIdxWithScene}, false, }, } @@ -995,6 +1026,107 @@ func TestPerformerQuery(t *testing.T) { } } +func TestPerformerQueryPenisLength(t *testing.T) { + var upper = 4.0 + + tests := []struct { + name string + modifier models.CriterionModifier + value float64 + value2 *float64 + }{ + { + "equals", + models.CriterionModifierEquals, + 1, + nil, + }, + { + "not equals", + models.CriterionModifierNotEquals, + 1, + nil, + }, + { + "greater than", + models.CriterionModifierGreaterThan, + 1, + nil, + }, + { + "between", + models.CriterionModifierBetween, + 2, + &upper, + }, + { + "greater than", + models.CriterionModifierNotBetween, + 2, + &upper, + }, + { + "null", + models.CriterionModifierIsNull, + 0, + nil, + }, + { + "not null", + models.CriterionModifierNotNull, + 0, + nil, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + filter := &models.PerformerFilterType{ + PenisLength: &models.FloatCriterionInput{ + Modifier: tt.modifier, + Value: tt.value, + Value2: tt.value2, + }, + } + + performers, _, err := db.Performer.Query(ctx, filter, nil) + if err != nil { + t.Errorf("PerformerStore.Query() error = %v", err) + return + } + + for _, p := range performers { + verifyFloat(t, p.PenisLength, *filter.PenisLength) + } + }) + } +} + +func verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool { + t.Helper() + assert := assert.New(t) + switch criterion.Modifier { + case models.CriterionModifierEquals: + return assert.NotNil(value) && assert.Equal(criterion.Value, *value) + case models.CriterionModifierNotEquals: + return assert.NotNil(value) && assert.NotEqual(criterion.Value, *value) + case models.CriterionModifierGreaterThan: + return assert.NotNil(value) && assert.Greater(*value, criterion.Value) + case models.CriterionModifierLessThan: + return assert.NotNil(value) && assert.Less(*value, criterion.Value) + case models.CriterionModifierBetween: + return assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2) + case models.CriterionModifierNotBetween: + return assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2) + case models.CriterionModifierIsNull: + return assert.Nil(value) + case models.CriterionModifierNotNull: + return assert.NotNil(value) + } + + return false +} + func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Performer diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index fbee73e86..5f4d31b55 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -3,6 +3,7 @@ package sqlite import ( "github.com/doug-martin/goqu/v9/exp" "github.com/stashapp/stash/pkg/models" + "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -77,11 +78,11 @@ func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { } } -// func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { -// if v.Set { -// r.set(destField, null.FloatFromPtr(v.Ptr())) -// } -// } +func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { + if v.Set { + r.set(destField, null.FloatFromPtr(v.Ptr())) + } +} func (r *updateRecord) setSQLiteTimestamp(destField string, v models.OptionalTime) { if v.Set { diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index affe3cd72..94c92035b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1331,6 +1331,29 @@ func getPerformerCareerLength(index int) *string { return &ret } +func getPerformerPenisLength(index int) *float64 { + if index%5 == 0 { + return nil + } + + ret := float64(index) + return &ret +} + +func getPerformerCircumcised(index int) *models.CircumisedEnum { + var ret models.CircumisedEnum + switch { + case index%3 == 0: + return nil + case index%3 == 1: + ret = models.CircumisedEnumCut + default: + ret = models.CircumisedEnumUncut + } + + return &ret +} + func getIgnoreAutoTag(index int) bool { return index%5 == 0 } @@ -1372,6 +1395,8 @@ func createPerformers(ctx context.Context, n int, o int) error { DeathDate: getPerformerDeathDate(i), Details: getPerformerStringValue(i, "Details"), Ethnicity: getPerformerStringValue(i, "Ethnicity"), + PenisLength: getPerformerPenisLength(i), + Circumcised: getPerformerCircumcised(i), Rating: getIntPtr(getRating(i)), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index a410bac28..90b922520 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -159,6 +159,22 @@ func getStringSearchClause(columns []string, q string, not bool) sqlClause { return makeClause("("+likes+")", args...) } +func getEnumSearchClause(column string, enumVals []string, not bool) sqlClause { + var args []interface{} + + notStr := "" + if not { + notStr = " NOT" + } + + clause := fmt.Sprintf("(%s%s IN %s)", column, notStr, getInBinding(len(enumVals))) + for _, enumVal := range enumVals { + args = append(args, enumVal) + } + + return makeClause(clause, args...) +} + func getInBinding(length int) string { bindings := strings.Repeat("?, ", length) bindings = strings.TrimRight(bindings, ", ") @@ -175,8 +191,26 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i upper = &u } - args := []interface{}{value} - betweenArgs := []interface{}{value, *upper} + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) { + return getFloatWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) { + if upper == nil { + u := 0.0 + upper = &u + } + + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) { + singleArgs := args[0:1] switch modifier { case models.CriterionModifierIsNull: @@ -184,20 +218,20 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i case models.CriterionModifierNotNull: return fmt.Sprintf("%s IS NOT NULL", column), nil case models.CriterionModifierEquals: - return fmt.Sprintf("%s = ?", column), args + return fmt.Sprintf("%s = ?", column), singleArgs case models.CriterionModifierNotEquals: - return fmt.Sprintf("%s != ?", column), args + return fmt.Sprintf("%s != ?", column), singleArgs case models.CriterionModifierBetween: - return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s BETWEEN ? AND ?", column), args case models.CriterionModifierNotBetween: - return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), args case models.CriterionModifierLessThan: - return fmt.Sprintf("%s < ?", column), args + return fmt.Sprintf("%s < ?", column), singleArgs case models.CriterionModifierGreaterThan: - return fmt.Sprintf("%s > ?", column), args + return fmt.Sprintf("%s > ?", column), singleArgs } - panic("unsupported int modifier type " + modifier) + panic("unsupported numeric modifier type " + modifier) } func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { diff --git a/pkg/sqlite/values.go b/pkg/sqlite/values.go index eafb8e462..be812275f 100644 --- a/pkg/sqlite/values.go +++ b/pkg/sqlite/values.go @@ -24,6 +24,15 @@ func nullIntPtr(i null.Int) *int { return &v } +func nullFloatPtr(i null.Float) *float64 { + if !i.Valid { + return nil + } + + v := float64(i.Float64) + return &v +} + func nullIntFolderIDPtr(i null.Int) *file.FolderID { if !i.Valid { return nil diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index 02e1fe67b..0b57f5f6e 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -31,3 +31,13 @@ func StrFormat(format string, m StrFormatMap) string { return strings.NewReplacer(args...).Replace(format) } + +// StringerSliceToStringSlice converts a slice of fmt.Stringers to a slice of strings. +func StringerSliceToStringSlice[V fmt.Stringer](v []V) []string { + ret := make([]string, len(v)) + for i, vv := range v { + ret[i] = vv.String() + } + + return ret +} diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 763d7c4f0..dd099cacd 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -36,7 +36,7 @@ import { StashIDFilter } from "./Filters/StashIDFilter"; import { RatingCriterion } from "../../models/list-filter/criteria/rating"; import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; -import { OptionsListFilter } from "./Filters/OptionsListFilter"; +import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; @@ -132,18 +132,17 @@ const GenericCriterionEditor: React.FC = ({ !criterionIsNumberValue(criterion.value) && !criterionIsStashIDValue(criterion.value) && !criterionIsDateValue(criterion.value) && - !criterionIsTimestampValue(criterion.value) && - !Array.isArray(criterion.value) + !criterionIsTimestampValue(criterion.value) ) { - // if (!modifierOptions || modifierOptions.length === 0) { - return ( - - ); - // } - - // return ( - // - // ); + if (!Array.isArray(criterion.value)) { + return ( + + ); + } else { + return ( + + ); + } } if (criterion.criterionOption instanceof PathCriterionOption) { return ( diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx index 0a04a4fc6..e9e2da084 100644 --- a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -30,14 +30,14 @@ export const BooleanFilter: React.FC = ({ id={`${criterion.getId()}-true`} onChange={() => onSelect(true)} checked={criterion.value === "true"} - type="checkbox" + type="radio" label={} /> onSelect(false)} checked={criterion.value === "false"} - type="checkbox" + type="radio" label={} /> diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx new file mode 100644 index 000000000..dad0e38cc --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx @@ -0,0 +1,85 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React from "react"; +import { Form } from "react-bootstrap"; +import { + CriterionValue, + Criterion, +} from "src/models/list-filter/criteria/criterion"; + +interface IOptionsFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + if (c.value === v) { + c.value = ""; + } else { + c.value = v; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={criterion.value === o.toString()} + type="radio" + label={o.toString()} + /> + ))} +
+ ); +}; + +interface IOptionsListFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionListFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + const cv = c.value as string[]; + if (cv.includes(v)) { + c.value = cv.filter((x) => x !== v); + } else { + c.value = [...cv, v]; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + const value = criterion.value as string[]; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={value.includes(o.toString())} + type="checkbox" + label={o.toString()} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx deleted file mode 100644 index 2f6f40bdc..000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useMemo } from "react"; -import { Form } from "react-bootstrap"; -import { - Criterion, - CriterionValue, -} from "../../../models/list-filter/criteria/criterion"; - -interface IOptionsFilterProps { - criterion: Criterion; - onValueChanged: (value: CriterionValue) => void; -} - -export const OptionsFilter: React.FC = ({ - criterion, - onValueChanged, -}) => { - function onChanged(event: React.ChangeEvent) { - onValueChanged(event.target.value); - } - - const options = useMemo(() => { - const ret = criterion.criterionOption.options?.slice() ?? []; - - ret.unshift(""); - - return ret; - }, [criterion.criterionOption.options]); - - return ( - - - {options.map((c) => ( - - ))} - - - ); -}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx deleted file mode 100644 index b84cf8bd1..000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; -import { Form } from "react-bootstrap"; -import { - CriterionValue, - Criterion, -} from "src/models/list-filter/criteria/criterion"; - -interface IOptionsListFilter { - criterion: Criterion; - setCriterion: (c: Criterion) => void; -} - -export const OptionsListFilter: React.FC = ({ - criterion, - setCriterion, -}) => { - function onSelect(v: string) { - const c = cloneDeep(criterion); - if (c.value === v) { - c.value = ""; - } else { - c.value = v; - } - - setCriterion(c); - } - - const { options } = criterion.criterionOption; - - return ( -
- {options?.map((o) => ( - onSelect(o.toString())} - checked={criterion.value === o.toString()} - type="checkbox" - label={o.toString()} - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index aff7fa268..892ac0989 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -17,6 +17,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; @@ -45,6 +50,8 @@ const performerFields = [ // "weight", "measurements", "fake_tits", + "penis_length", + "circumcised", "hair_color", "tattoos", "piercings", @@ -64,10 +71,12 @@ export const EditPerformersDialog: React.FC = ( useState({}); // weight needs conversion to/from number const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); + const circumcisedOptions = [""].concat(circumcisedStrings); const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); @@ -100,11 +109,19 @@ export const EditPerformersDialog: React.FC = ( updateInput.gender, aggregateState.gender ); + performerInput.circumcised = getAggregateInputValue( + updateInput.circumcised, + aggregateState.circumcised + ); if (weight !== undefined) { performerInput.weight = parseFloat(weight); } + if (penis_length !== undefined) { + performerInput.penis_length = parseFloat(penis_length); + } + return performerInput; } @@ -135,6 +152,7 @@ export const EditPerformersDialog: React.FC = ( const state = props.selected; let updateTagIds: string[] = []; let updateWeight: string | undefined | null = undefined; + let updatePenisLength: string | undefined | null = undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { @@ -151,6 +169,16 @@ export const EditPerformersDialog: React.FC = ( : performer.weight; updateWeight = getAggregateState(updateWeight, thisWeight, first); + const thisPenisLength = + performer.penis_length !== undefined && performer.penis_length !== null + ? performer.penis_length.toString() + : performer.penis_length; + updatePenisLength = getAggregateState( + updatePenisLength, + thisPenisLength, + first + ); + first = false; }); @@ -270,6 +298,32 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("measurements", updateInput.measurements, (v) => setUpdateField({ measurements: v }) )} + {renderTextField("penis_length", penis_length, (v) => + setPenisLength(v) + )} + + + + + + + setUpdateField({ + circumcised: stringToCircumcised(event.currentTarget.value), + }) + } + > + {circumcisedOptions.map((opt) => ( + + ))} + + + {renderTextField("fake_tits", updateInput.fake_tits, (v) => setUpdateField({ fake_tits: v }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 9a0aa9f07..514258a38 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -6,7 +6,7 @@ import TextUtils from "src/utils/text"; import { getStashboxBase } from "src/utils/stashbox"; import { getCountryByISO } from "src/utils/country"; import { TextField, URLField } from "src/utils/field"; -import { cmToImperial, kgToLbs } from "src/utils/units"; +import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -133,6 +133,49 @@ export const PerformerDetailsPanel: React.FC = ({ ); }; + const formatPenisLength = (penis_length?: number | null) => { + if (!penis_length) { + return ""; + } + + const inches = cmToInches(penis_length); + + return ( + + + {intl.formatNumber(penis_length, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + maximumFractionDigits: 2, + })} + + + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + maximumFractionDigits: 2, + })} + + + ); + }; + + const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { + if (!circumcised) { + return ""; + } + + return ( + + {intl.formatMessage({ + id: "circumcised_types." + performer.circumcised, + })} + + ); + }; + return (
= ({ )} + {(performer.penis_length || performer.circumcised) && ( + <> +
+ : +
+
+ {formatPenisLength(performer.penis_length)} + {formatCircumcised(performer.circumcised)} +
+ + )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index e8c2ef028..03f2dd128 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -31,6 +31,11 @@ import { stringGenderMap, stringToGender, } from "src/utils/gender"; +import { + circumcisedToString, + stringCircumMap, + stringToCircumcised, +} from "src/utils/circumcised"; import { ConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; @@ -153,6 +158,8 @@ export const PerformerEditPanel: React.FC = ({ weight: yup.number().nullable().defined().default(null), measurements: yup.string().ensure(), fake_tits: yup.string().ensure(), + penis_length: yup.number().nullable().defined().default(null), + circumcised: yup.string().ensure(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_length: yup.string().ensure(), @@ -181,6 +188,8 @@ export const PerformerEditPanel: React.FC = ({ weight: performer.weight ?? null, measurements: performer.measurements ?? "", fake_tits: performer.fake_tits ?? "", + penis_length: performer.penis_length ?? null, + circumcised: (performer.circumcised as GQL.CircumisedEnum) ?? "", tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", @@ -219,6 +228,21 @@ export const PerformerEditPanel: React.FC = ({ } } + function translateScrapedCircumcised(scrapedCircumcised?: string) { + if (!scrapedCircumcised) { + return; + } + + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + return stringToCircumcised(asEnum); + } else { + const caseInsensitive = true; + return stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + } + function renderNewTags() { if (!newTags || newTags.length === 0) { return; @@ -355,6 +379,13 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("gender", newGender); } } + if (state.circumcised) { + // circumcised is a string in the scraper data + const newCircumcised = translateScrapedCircumcised(state.circumcised); + if (newCircumcised) { + formik.setFieldValue("circumcised", newCircumcised); + } + } if (state.tags) { // map tags to their ids and filter out those not found const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); @@ -387,6 +418,9 @@ export const PerformerEditPanel: React.FC = ({ if (state.weight) { formik.setFieldValue("weight", state.weight); } + if (state.penis_length) { + formik.setFieldValue("penis_length", state.penis_length); + } const remoteSiteID = state.remote_site_id; if (remoteSiteID && (scraper as IStashBox).endpoint) { @@ -431,6 +465,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -446,6 +482,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -663,6 +701,7 @@ export const PerformerEditPanel: React.FC = ({ const currentPerformer = { ...formik.values, gender: formik.values.gender || null, + circumcised: formik.values.circumcised || null, image: formik.values.image ?? performer.image_path, }; @@ -990,6 +1029,31 @@ export const PerformerEditPanel: React.FC = ({ type: "number", messageID: "weight_kg", })} + {renderField("penis_length", { + type: "number", + messageID: "penis_length_cm", + })} + + + + + + + + + {Array.from(stringCircumMap.entries()).map(([name, value]) => ( + + ))} + + + + {renderField("measurements")} {renderField("fake_tits")} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 6a6a006f7..90bd6f70c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -20,6 +20,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; function renderScrapedGender( @@ -120,6 +125,55 @@ function renderScrapedTagsRow( ); } +function renderScrapedCircumcised( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: string) => void +) { + const selectOptions = [""].concat(circumcisedStrings); + + return ( + { + if (isNew && onChange) { + onChange(e.currentTarget.value); + } + }} + > + {selectOptions.map((opt) => ( + + ))} + + ); +} + +function renderScrapedCircumcisedRow( + title: string, + result: ScrapeResult, + onChange: (value: ScrapeResult) => void +) { + return ( + renderScrapedCircumcised(result)} + renderNewField={() => + renderScrapedCircumcised(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + onChange={onChange} + /> + ); +} + interface IPerformerScrapeDialogProps { performer: Partial; scraped: GQL.ScrapedPerformer; @@ -165,6 +219,27 @@ export const PerformerScrapeDialog: React.FC = ( return genderToString(retEnum); } + function translateScrapedCircumcised(scrapedCircumcised?: string | null) { + if (!scrapedCircumcised) { + return; + } + + let retEnum: GQL.CircumisedEnum | undefined; + + // try to translate from enum values first + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + retEnum = stringToCircumcised(asEnum); + } else { + // try to match against circumcised strings + const caseInsensitive = true; + retEnum = stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + + return circumcisedToString(retEnum); + } + const [name, setName] = useState>( new ScrapeResult(props.performer.name, props.scraped.name) ); @@ -216,6 +291,12 @@ export const PerformerScrapeDialog: React.FC = ( props.scraped.weight ) ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult( + props.performer.penis_length?.toString(), + props.scraped.penis_length + ) + ); const [measurements, setMeasurements] = useState>( new ScrapeResult( props.performer.measurements, @@ -252,6 +333,12 @@ export const PerformerScrapeDialog: React.FC = ( translateScrapedGender(props.scraped.gender) ) ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult( + circumcisedToString(props.performer.circumcised), + translateScrapedCircumcised(props.scraped.circumcised) + ) + ); const [details, setDetails] = useState>( new ScrapeResult(props.performer.details, props.scraped.details) ); @@ -338,6 +425,8 @@ export const PerformerScrapeDialog: React.FC = ( height, measurements, fakeTits, + penisLength, + circumcised, careerLength, tattoos, piercings, @@ -426,6 +515,8 @@ export const PerformerScrapeDialog: React.FC = ( death_date: deathDate.getNewValue(), hair_color: hairColor.getNewValue(), weight: weight.getNewValue(), + penis_length: penisLength.getNewValue(), + circumcised: circumcised.getNewValue(), remote_site_id: remoteSiteID.getNewValue(), }; } @@ -493,6 +584,16 @@ export const PerformerScrapeDialog: React.FC = ( result={height} onChange={(value) => setHeight(value)} /> + setPenisLength(value)} + /> + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} { death_date: toCreate.death_date, hair_color: toCreate.hair_color, weight: toCreate.weight ? Number(toCreate.weight) : undefined, + penis_length: toCreate.penis_length + ? Number(toCreate.penis_length) + : undefined, + circumcised: stringToCircumcised(toCreate.circumcised), }; return input; }; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8827d38bc..5a84cba9b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -141,6 +141,11 @@ "captions": "Captions", "career_length": "Career Length", "chapters": "Chapters", + "circumcised": "Circumcised", + "circumcised_types": { + "UNCUT": "Uncut", + "CUT": "Cut" + }, "component_tagger": { "config": { "active_instance": "Active stash-box instance:", @@ -1016,6 +1021,9 @@ "parent_tags": "Parent Tags", "part_of": "Part of {parent}", "path": "Path", + "penis": "Penis", + "penis_length": "Penis Length", + "penis_length_cm": "Penis Length (cm)", "perceptual_similarity": "Perceptual Similarity (phash)", "performer": "Performer", "performerTags": "Performer Tags", diff --git a/ui/v2.5/src/models/list-filter/criteria/circumcised.ts b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts new file mode 100644 index 000000000..c18aa1b01 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts @@ -0,0 +1,36 @@ +import { + CircumcisionCriterionInput, + CircumisedEnum, + CriterionModifier, +} from "src/core/generated-graphql"; +import { circumcisedStrings, stringToCircumcised } from "src/utils/circumcised"; +import { CriterionOption, MultiStringCriterion } from "./criterion"; + +export const CircumcisedCriterionOption = new CriterionOption({ + messageID: "circumcised", + type: "circumcised", + options: circumcisedStrings, + modifierOptions: [ + CriterionModifier.Includes, + CriterionModifier.Excludes, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], +}); + +export class CircumcisedCriterion extends MultiStringCriterion { + constructor() { + super(CircumcisedCriterionOption); + } + + protected toCriterionInput(): CircumcisionCriterionInput { + const value = this.value.map((v) => + stringToCircumcised(v) + ) as CircumisedEnum[]; + + return { + value, + modifier: this.modifier, + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 7dc299a77..642fe7336 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -28,6 +28,7 @@ import { export type Option = string | number | IOptionType; export type CriterionValue = | string + | string[] | ILabeledId[] | IHierarchicalLabelValue | INumberValue @@ -243,6 +244,24 @@ export class StringCriterion extends Criterion { } } +export class MultiStringCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, []); + } + + public getLabelValue(_intl: IntlShape) { + return this.value.join(", "); + } + + public isValid(): boolean { + return ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.value.length > 0 + ); + } +} + export class MandatoryStringCriterionOption extends CriterionOption { constructor( messageID: string, diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index f6c96cab8..311b78728 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -44,6 +44,7 @@ import { TagsCriterionOption, } from "./tags"; import { GenderCriterion } from "./gender"; +import { CircumcisedCriterion } from "./circumcised"; import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; @@ -155,12 +156,16 @@ export function makeCriteria( case "death_year": case "weight": return new NumberCriterion(new NumberCriterionOption(type, type)); + case "penis_length": + return new NumberCriterion(new NumberCriterionOption(type, type)); case "age": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); case "gender": return new GenderCriterion(); + case "circumcised": + return new CircumcisedCriterion(); case "sceneChecksum": case "galleryChecksum": return new StringCriterion( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 5a628ca2a..2995aebb7 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -10,6 +10,7 @@ import { } from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; import { GenderCriterionOption } from "./criteria/gender"; +import { CircumcisedCriterionOption } from "./criteria/circumcised"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -25,6 +26,7 @@ const sortByOptions = [ "tag_count", "random", "rating", + "penis_length", ] .map(ListFilterOptions.createSortBy) .concat([ @@ -57,6 +59,7 @@ const numberCriteria: CriterionType[] = [ "death_year", "age", "weight", + "penis_length", ]; const stringCriteria: CriterionType[] = [ @@ -78,6 +81,7 @@ const stringCriteria: CriterionType[] = [ const criterionOptions = [ FavoriteCriterionOption, GenderCriterionOption, + CircumcisedCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, StudiosCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index e105e8ab8..548adc59f 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -134,6 +134,8 @@ export type CriterionType = | "weight" | "measurements" | "fake_tits" + | "penis_length" + | "circumcised" | "career_length" | "tattoos" | "piercings" diff --git a/ui/v2.5/src/utils/circumcised.ts b/ui/v2.5/src/utils/circumcised.ts new file mode 100644 index 000000000..b922a7795 --- /dev/null +++ b/ui/v2.5/src/utils/circumcised.ts @@ -0,0 +1,51 @@ +import * as GQL from "../core/generated-graphql"; + +export const stringCircumMap = new Map([ + ["Uncut", GQL.CircumisedEnum.Uncut], + ["Cut", GQL.CircumisedEnum.Cut], +]); + +export const circumcisedToString = ( + value?: GQL.CircumisedEnum | String | null +) => { + if (!value) { + return undefined; + } + + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[1] === value; + }); + + if (foundEntry) { + return foundEntry[0]; + } +}; + +export const stringToCircumcised = ( + value?: string | null, + caseInsensitive?: boolean +): GQL.CircumisedEnum | undefined => { + if (!value) { + return undefined; + } + + const existing = Object.entries(GQL.CircumisedEnum).find( + (e) => e[1] === value + ); + if (existing) return existing[1]; + + const ret = stringCircumMap.get(value); + if (ret || !caseInsensitive) { + return ret; + } + const asUpper = value.toUpperCase(); + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[0].toUpperCase() === asUpper; + }); + + if (foundEntry) { + return foundEntry[1]; + } +}; + +export const circumcisedStrings = Array.from(stringCircumMap.keys()); diff --git a/ui/v2.5/src/utils/units.ts b/ui/v2.5/src/utils/units.ts index 3115eed5f..f0cae7e52 100644 --- a/ui/v2.5/src/utils/units.ts +++ b/ui/v2.5/src/utils/units.ts @@ -9,3 +9,9 @@ export function cmToImperial(cm: number) { export function kgToLbs(kg: number) { return Math.round(kg * 2.20462262185); } + +export function cmToInches(cm: number) { + const cmInInches = 0.393700787; + const inches = cm * cmInInches; + return inches; +}