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>
This commit is contained in:
departure18
2023-05-24 04:19:35 +01:00
committed by GitHub
parent 58a6c22072
commit 776c7e6c35
52 changed files with 1051 additions and 184 deletions

View File

@@ -16,6 +16,8 @@ fragment SlimPerformerData on Performer {
eye_color
height_cm
fake_tits
penis_length
circumcised
career_length
tattoos
piercings

View File

@@ -14,6 +14,8 @@ fragment PerformerData on Performer {
height_cm
measurements
fake_tits
penis_length
circumcised
career_length
tattoos
piercings

View File

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

View File

@@ -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!]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

@@ -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"`

View File

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

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

@@ -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"`

View File

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

View File

@@ -32,7 +32,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 45
var appSchemaVersion uint = 46
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE `performers` ADD COLUMN `penis_length` float;
ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<IGenericCriterionEditor> = ({
!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 (
<OptionsListFilter criterion={criterion} setCriterion={setCriterion} />
);
// }
// return (
// <OptionsFilter criterion={criterion} onValueChanged={onValueChanged} />
// );
if (!Array.isArray(criterion.value)) {
return (
<OptionFilter criterion={criterion} setCriterion={setCriterion} />
);
} else {
return (
<OptionListFilter criterion={criterion} setCriterion={setCriterion} />
);
}
}
if (criterion.criterionOption instanceof PathCriterionOption) {
return (

View File

@@ -30,14 +30,14 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
id={`${criterion.getId()}-true`}
onChange={() => onSelect(true)}
checked={criterion.value === "true"}
type="checkbox"
type="radio"
label={<FormattedMessage id="true" />}
/>
<Form.Check
id={`${criterion.getId()}-false`}
onChange={() => onSelect(false)}
checked={criterion.value === "false"}
type="checkbox"
type="radio"
label={<FormattedMessage id="false" />}
/>
</div>

View File

@@ -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<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
}
export const OptionFilter: React.FC<IOptionsFilter> = ({
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 (
<div className="option-list-filter">
{options?.map((o) => (
<Form.Check
id={`${criterion.getId()}-${o.toString()}`}
key={o.toString()}
onChange={() => onSelect(o.toString())}
checked={criterion.value === o.toString()}
type="radio"
label={o.toString()}
/>
))}
</div>
);
};
interface IOptionsListFilter {
criterion: Criterion<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
}
export const OptionListFilter: React.FC<IOptionsListFilter> = ({
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 (
<div className="option-list-filter">
{options?.map((o) => (
<Form.Check
id={`${criterion.getId()}-${o.toString()}`}
key={o.toString()}
onChange={() => onSelect(o.toString())}
checked={value.includes(o.toString())}
type="checkbox"
label={o.toString()}
/>
))}
</div>
);
};

View File

@@ -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<CriterionValue>;
onValueChanged: (value: CriterionValue) => void;
}
export const OptionsFilter: React.FC<IOptionsFilterProps> = ({
criterion,
onValueChanged,
}) => {
function onChanged(event: React.ChangeEvent<HTMLSelectElement>) {
onValueChanged(event.target.value);
}
const options = useMemo(() => {
const ret = criterion.criterionOption.options?.slice() ?? [];
ret.unshift("");
return ret;
}, [criterion.criterionOption.options]);
return (
<Form.Group>
<Form.Control
as="select"
onChange={onChanged}
value={criterion.value.toString()}
className="btn-secondary"
>
{options.map((c) => (
<option key={c.toString()} value={c.toString()}>
{c ? c : "---"}
</option>
))}
</Form.Control>
</Form.Group>
);
};

View File

@@ -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<CriterionValue>;
setCriterion: (c: Criterion<CriterionValue>) => void;
}
export const OptionsListFilter: React.FC<IOptionsListFilter> = ({
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 (
<div className="option-list-filter">
{options?.map((o) => (
<Form.Check
id={`${criterion.getId()}-${o.toString()}`}
key={o.toString()}
onChange={() => onSelect(o.toString())}
checked={criterion.value === o.toString()}
type="checkbox"
label={o.toString()}
/>
))}
</div>
);
};

View File

@@ -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<IListOperationProps> = (
useState<GQL.BulkPerformerUpdateInput>({});
// weight needs conversion to/from number
const [weight, setWeight] = useState<string | undefined>();
const [penis_length, setPenisLength] = useState<string | undefined>();
const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(
{}
);
const genderOptions = [""].concat(genderStrings);
const circumcisedOptions = [""].concat(circumcisedStrings);
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
@@ -100,11 +109,19 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
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<IListOperationProps> = (
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<IListOperationProps> = (
: 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<IListOperationProps> = (
{renderTextField("measurements", updateInput.measurements, (v) =>
setUpdateField({ measurements: v })
)}
{renderTextField("penis_length", penis_length, (v) =>
setPenisLength(v)
)}
<Form.Group>
<Form.Label>
<FormattedMessage id="circumcised" />
</Form.Label>
<Form.Control
as="select"
className="input-control"
value={circumcisedToString(updateInput.circumcised)}
onChange={(event) =>
setUpdateField({
circumcised: stringToCircumcised(event.currentTarget.value),
})
}
>
{circumcisedOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
</Form.Group>
{renderTextField("fake_tits", updateInput.fake_tits, (v) =>
setUpdateField({ fake_tits: v })
)}

View File

@@ -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<IPerformerDetails> = ({
);
};
const formatPenisLength = (penis_length?: number | null) => {
if (!penis_length) {
return "";
}
const inches = cmToInches(penis_length);
return (
<span className="performer-penis-length">
<span className="penis-length-metric">
{intl.formatNumber(penis_length, {
style: "unit",
unit: "centimeter",
unitDisplay: "short",
maximumFractionDigits: 2,
})}
</span>
<span className="penis-length-imperial">
{intl.formatNumber(inches, {
style: "unit",
unit: "inch",
unitDisplay: "narrow",
maximumFractionDigits: 2,
})}
</span>
</span>
);
};
const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
if (!circumcised) {
return "";
}
return (
<span className="penis-circumcised">
{intl.formatMessage({
id: "circumcised_types." + performer.circumcised,
})}
</span>
);
};
return (
<dl className="details-list">
<TextField
@@ -179,6 +222,17 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
</>
)}
{(performer.penis_length || performer.circumcised) && (
<>
<dt>
<FormattedMessage id="penis" />:
</dt>
<dd>
{formatPenisLength(performer.penis_length)}
{formatCircumcised(performer.circumcised)}
</dd>
</>
)}
<TextField id="measurements" value={performer.measurements} />
<TextField id="fake_tits" value={performer.fake_tits} />
<TextField id="career_length" value={performer.career_length} />

View File

@@ -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<IPerformerDetails> = ({
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<GQL.CircumisedEnum | "">().ensure(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
career_length: yup.string().ensure(),
@@ -181,6 +188,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
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<IPerformerDetails> = ({
}
}
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<IPerformerDetails> = ({
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<IPerformerDetails> = ({
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<IPerformerDetails> = ({
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<IPerformerDetails> = ({
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<IPerformerDetails> = ({
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<IPerformerDetails> = ({
type: "number",
messageID: "weight_kg",
})}
{renderField("penis_length", {
type: "number",
messageID: "penis_length_cm",
})}
<Form.Group as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
<FormattedMessage id="circumcised" />
</Form.Label>
<Col xs="auto">
<Form.Control
as="select"
className="input-control"
{...formik.getFieldProps("circumcised")}
>
<option value="" key=""></option>
{Array.from(stringCircumMap.entries()).map(([name, value]) => (
<option value={value} key={value}>
{name}
</option>
))}
</Form.Control>
</Col>
</Form.Group>
{renderField("measurements")}
{renderField("fake_tits")}

View File

@@ -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<string>,
isNew?: boolean,
onChange?: (value: string) => void
) {
const selectOptions = [""].concat(circumcisedStrings);
return (
<Form.Control
as="select"
className="input-control"
disabled={!isNew}
plaintext={!isNew}
value={isNew ? result.newValue : result.originalValue}
onChange={(e) => {
if (isNew && onChange) {
onChange(e.currentTarget.value);
}
}}
>
{selectOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
);
}
function renderScrapedCircumcisedRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
) {
return (
<ScrapeDialogRow
title={title}
result={result}
renderOriginalField={() => renderScrapedCircumcised(result)}
renderNewField={() =>
renderScrapedCircumcised(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange}
/>
);
}
interface IPerformerScrapeDialogProps {
performer: Partial<GQL.PerformerUpdateInput>;
scraped: GQL.ScrapedPerformer;
@@ -165,6 +219,27 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
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<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.name, props.scraped.name)
);
@@ -216,6 +291,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
props.scraped.weight
)
);
const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.penis_length?.toString(),
props.scraped.penis_length
)
);
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.measurements,
@@ -252,6 +333,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
translateScrapedGender(props.scraped.gender)
)
);
const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
circumcisedToString(props.performer.circumcised),
translateScrapedCircumcised(props.scraped.circumcised)
)
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.details, props.scraped.details)
);
@@ -338,6 +425,8 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
height,
measurements,
fakeTits,
penisLength,
circumcised,
careerLength,
tattoos,
piercings,
@@ -426,6 +515,8 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
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<IPerformerScrapeDialogProps> = (
result={height}
onChange={(value) => setHeight(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "penis_length" })}
result={penisLength}
onChange={(value) => setPenisLength(value)}
/>
{renderScrapedCircumcisedRow(
intl.formatMessage({ id: "circumcised" }),
circumcised,
(value) => setCircumcised(value)
)}
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "measurements" })}
result={measurements}

View File

@@ -190,27 +190,21 @@
color: #c8a2c8;
}
.performer-height {
.height-imperial {
&::before {
content: " (";
}
.performer-height .height-imperial,
.performer-weight .weight-imperial,
.performer-penis-length .penis-length-imperial {
&::before {
content: " (";
}
&::after {
content: ")";
}
&::after {
content: ")";
}
}
.performer-weight {
.weight-imperial {
&::before {
content: " (";
}
&::after {
content: ")";
}
.penis-circumcised {
&::before {
content: " ";
}
}

View File

@@ -6,6 +6,7 @@ import {
getOperationName,
} from "@apollo/client/utilities";
import { stringToGender } from "src/utils/gender";
import { stringToCircumcised } from "src/utils/circumcised";
import { filterData } from "../utils/data";
import { ListFilterModel } from "../models/list-filter/filter";
import * as GQL from "./generated-graphql";
@@ -1314,6 +1315,10 @@ export const makePerformerCreateInput = (toCreate: GQL.ScrapedPerformer) => {
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;
};

View File

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

View File

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

View File

@@ -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<string> {
}
}
export class MultiStringCriterion extends Criterion<string[]> {
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,

View File

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

View File

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

View File

@@ -134,6 +134,8 @@ export type CriterionType =
| "weight"
| "measurements"
| "fake_tits"
| "penis_length"
| "circumcised"
| "career_length"
| "tattoos"
| "piercings"

View File

@@ -0,0 +1,51 @@
import * as GQL from "../core/generated-graphql";
export const stringCircumMap = new Map<string, GQL.CircumisedEnum>([
["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());

View File

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