({
// set up hotkeys
useEffect(() => {
- Mousetrap.bind("f", () => setShowEditFilter(true));
+ Mousetrap.bind("f", (e) => {
+ setShowEditFilter(true);
+ // prevent default behavior of typing f in a text field
+ // otherwise the filter dialog closes, the query field is focused and
+ // f is typed.
+ e.preventDefault();
+ });
return () => {
Mousetrap.unbind("f");
diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss
index cec9da69e..8b4c67827 100644
--- a/ui/v2.5/src/components/List/styles.scss
+++ b/ui/v2.5/src/components/List/styles.scss
@@ -118,6 +118,15 @@ input[type="range"].zoom-slider {
}
.edit-filter-dialog {
+ .modal-header {
+ align-items: center;
+ padding: 0.5rem 1rem;
+
+ .search-input {
+ width: auto;
+ }
+ }
+
.modal-body {
padding-left: 0;
padding-right: 0;
diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md
index 7eee67ef4..98c13cad0 100644
--- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md
+++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md
@@ -24,7 +24,7 @@
| Keyboard sequence | Action |
|-------------------|--------|
-| `/` | Focus search field |
+| `/` | Focus search field / focus query field in filter dialog |
| `f` | Show Add Filter dialog |
| `r` | Reshuffle if sorted by random |
| `v g` | Set view to grid |
diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts
index 0ab1e3b68..189920752 100644
--- a/ui/v2.5/src/utils/focus.ts
+++ b/ui/v2.5/src/utils/focus.ts
@@ -1,4 +1,4 @@
-import { useRef } from "react";
+import { useRef, useEffect } from "react";
const useFocus = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,4 +14,19 @@ const useFocus = () => {
return [htmlElRef, setFocus] as const;
};
+// focuses on the element only once on mount
+export const useFocusOnce = () => {
+ const [htmlElRef, setFocus] = useFocus();
+ const focused = useRef(false);
+
+ useEffect(() => {
+ if (!focused.current) {
+ setFocus();
+ focused.current = true;
+ }
+ }, [setFocus]);
+
+ return [htmlElRef, setFocus] as const;
+};
+
export default useFocus;
From 124adb3f5bf0b4f41f8bdcd2f09e3d9c04f2ab7b Mon Sep 17 00:00:00 2001
From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com>
Date: Tue, 23 May 2023 03:40:27 +0200
Subject: [PATCH 53/81] Fix bulk performer update plugin hook (#3754)
---
internal/api/resolver_mutation_performer.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go
index 88aab07d0..5b9304ba3 100644
--- a/internal/api/resolver_mutation_performer.go
+++ b/internal/api/resolver_mutation_performer.go
@@ -418,7 +418,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
// execute post hooks outside of txn
var newRet []*models.Performer
for _, performer := range ret {
- r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.ImageUpdatePost, input, translator.getFields())
+ r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.PerformerUpdatePost, input, translator.getFields())
performer, err = r.getPerformer(ctx, performer.ID)
if err != nil {
From 58a6c2207240f1479568cea0b95d0e72d4730bb2 Mon Sep 17 00:00:00 2001
From: CJ <72030708+Teda1@users.noreply.github.com>
Date: Tue, 23 May 2023 00:07:06 -0500
Subject: [PATCH 54/81] honor dlna sort order to content exceeding the first
page (#3747)
---
internal/dlna/cds.go | 18 +++++++++++++-----
internal/dlna/paging.go | 10 +++++-----
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go
index cf5deaa7c..22cc17718 100644
--- a/internal/dlna/cds.go
+++ b/internal/dlna/cds.go
@@ -440,15 +440,21 @@ func getRootObjects() []interface{} {
return objs
}
+func getSortDirection(sceneFilter *models.SceneFilterType, sort string) models.SortDirectionEnum {
+ direction := models.SortDirectionEnumDesc
+ if sort == "title" {
+ direction = models.SortDirectionEnumAsc
+ }
+
+ return direction
+}
+
func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} {
var objs []interface{}
if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error {
sort := me.VideoSortOrder
- direction := models.SortDirectionEnumDesc
- if sort == "title" {
- direction = models.SortDirectionEnumAsc
- }
+ direction := getSortDirection(sceneFilter, sort)
findFilter := &models.FindFilterType{
PerPage: &pageSize,
Sort: &sort,
@@ -497,8 +503,10 @@ func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilter
parentID: parentID,
}
+ sort := me.VideoSortOrder
+ direction := getSortDirection(sceneFilter, sort)
var err error
- objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host)
+ objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host, sort, direction)
if err != nil {
return err
}
diff --git a/internal/dlna/paging.go b/internal/dlna/paging.go
index d5643da88..bd1b00283 100644
--- a/internal/dlna/paging.go
+++ b/internal/dlna/paging.go
@@ -60,14 +60,14 @@ func (p *scenePager) getPages(ctx context.Context, r scene.Queryer, total int) (
return objs, nil
}
-func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string) ([]interface{}, error) {
+func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string, sort string, direction models.SortDirectionEnum) ([]interface{}, error) {
var objs []interface{}
- sort := "title"
findFilter := &models.FindFilterType{
- PerPage: &pageSize,
- Page: &page,
- Sort: &sort,
+ PerPage: &pageSize,
+ Page: &page,
+ Sort: &sort,
+ Direction: &direction,
}
scenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter)
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 55/81] 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