FR: Change Career Length to Career Start and Career End (#6449)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
Gykes 2026-02-16 20:44:03 -06:00 committed by GitHub
parent bede849fa6
commit adaadee368
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1004 additions and 132 deletions

View file

@ -140,4 +140,8 @@ models:
fields:
plugins:
resolver: true
Performer:
fields:
career_length:
resolver: true

View file

@ -154,8 +154,13 @@ input PerformerFilterType {
penis_length: FloatCriterionInput
"Filter by ciricumcision"
circumcised: CircumcisionCriterionInput
"Filter by career length"
"Deprecated: use career_start and career_end. This filter is non-functional."
career_length: StringCriterionInput
@deprecated(reason: "Use career_start and career_end")
"Filter by career start year"
career_start: IntCriterionInput
"Filter by career end year"
career_end: IntCriterionInput
"Filter by tattoos"
tattoos: StringCriterionInput
"Filter by piercings"

View file

@ -30,7 +30,9 @@ type Performer {
fake_tits: String
penis_length: Float
circumcised: CircumisedEnum
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
alias_list: [String!]!
@ -77,7 +79,9 @@ input PerformerCreateInput {
fake_tits: String
penis_length: Float
circumcised: CircumisedEnum
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
@ -116,7 +120,9 @@ input PerformerUpdateInput {
fake_tits: String
penis_length: Float
circumcised: CircumisedEnum
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
@ -160,7 +166,9 @@ input BulkPerformerUpdateInput {
fake_tits: String
penis_length: Float
circumcised: CircumisedEnum
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"

View file

@ -18,7 +18,9 @@ type ScrapedPerformer {
fake_tits: String
penis_length: String
circumcised: String
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
# aliases must be comma-delimited to be parsed correctly
@ -54,7 +56,9 @@ input ScrapedPerformerInput {
fake_tits: String
penis_length: String
circumcised: String
career_length: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
tattoos: String
piercings: String
aliases: String

View file

@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils"
)
func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {
@ -109,6 +110,15 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer)
return obj.Height, nil
}
func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerStart == nil && obj.CareerEnd == nil {
return nil, nil
}
ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd)
return &ret, nil
}
func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Birthdate != nil {
ret := obj.Birthdate.String()

View file

@ -52,7 +52,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.FakeTits = translator.string(input.FakeTits)
newPerformer.PenisLength = input.PenisLength
newPerformer.Circumcised = input.Circumcised
newPerformer.CareerLength = translator.string(input.CareerLength)
newPerformer.CareerStart = input.CareerStart
newPerformer.CareerEnd = input.CareerEnd
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
newPerformer.CareerStart = start
newPerformer.CareerEnd = end
}
newPerformer.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Favorite = translator.bool(input.Favorite)
@ -261,7 +271,22 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
@ -417,7 +442,22 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := utils.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")

View file

@ -48,7 +48,9 @@ type Performer struct {
FakeTits string `json:"fake_tits,omitempty"`
PenisLength float64 `json:"penis_length,omitempty"`
Circumcised string `json:"circumcised,omitempty"`
CareerLength string `json:"career_length,omitempty"`
CareerLength string `json:"career_length,omitempty"` // deprecated - for import only
CareerStart *int `json:"career_start,omitempty"`
CareerEnd *int `json:"career_end,omitempty"`
Tattoos string `json:"tattoos,omitempty"`
Piercings string `json:"piercings,omitempty"`
Aliases StringOrStringList `json:"aliases,omitempty"`

View file

@ -19,7 +19,8 @@ type Performer struct {
FakeTits string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerLength string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos string `json:"tattoos"`
Piercings string `json:"piercings"`
Favorite bool `json:"favorite"`
@ -75,7 +76,8 @@ type PerformerPartial struct {
FakeTits OptionalString
PenisLength OptionalFloat64
Circumcised OptionalString
CareerLength OptionalString
CareerStart OptionalInt
CareerEnd OptionalInt
Tattoos OptionalString
Piercings OptionalString
Favorite OptionalBool

View file

@ -176,7 +176,9 @@ type ScrapedPerformer struct {
FakeTits *string `json:"fake_tits"`
PenisLength *string `json:"penis_length"`
Circumcised *string `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
@ -219,8 +221,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
ret.DeathDate = &date
}
}
if p.CareerLength != nil && !excluded["career_length"] {
ret.CareerLength = *p.CareerLength
// assume that career length is _not_ populated in favour of start/end
if p.CareerStart != nil && !excluded["career_start"] {
cs := *p.CareerStart
ret.CareerStart = &cs
}
if p.CareerEnd != nil && !excluded["career_end"] {
ce := *p.CareerEnd
ret.CareerEnd = &ce
}
if p.Country != nil && !excluded["country"] {
ret.Country = *p.Country
@ -356,7 +366,16 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
}
}
if p.CareerLength != nil && !excluded["career_length"] {
ret.CareerLength = NewOptionalString(*p.CareerLength)
// parse career_length into career_start/career_end
start, end, err := utils.ParseYearRangeString(*p.CareerLength)
if err == nil {
if start != nil {
ret.CareerStart = NewOptionalInt(*start)
}
if end != nil {
ret.CareerEnd = NewOptionalInt(*end)
}
}
}
if p.Country != nil && !excluded["country"] {
ret.Country = NewOptionalString(*p.Country)

View file

@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
)
func intPtr(i int) *int { return &i }
func Test_scrapedToStudioInput(t *testing.T) {
const name = "name"
url := "url"
@ -124,9 +126,10 @@ func Test_scrapedToPerformerInput(t *testing.T) {
endpoint := "endpoint"
remoteSiteID := "remoteSiteID"
var stringValues []string
for i := 0; i < 20; i++ {
stringValues = append(stringValues, strconv.Itoa(i))
const nValues = 19
stringValues := make([]string, nValues)
for i := 0; i < nValues; i++ {
stringValues[i] = strconv.Itoa(i)
}
upTo := 0
@ -183,7 +186,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Weight: nextVal(),
Measurements: nextVal(),
FakeTits: nextVal(),
CareerLength: nextVal(),
CareerStart: intPtr(2005),
CareerEnd: intPtr(2015),
Tattoos: nextVal(),
Piercings: nextVal(),
Aliases: nextVal(),
@ -208,8 +212,9 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Weight: nextIntVal(),
Measurements: *nextVal(),
FakeTits: *nextVal(),
CareerLength: *nextVal(),
Tattoos: *nextVal(),
CareerStart: intPtr(2005),
CareerEnd: intPtr(2015),
Tattoos: *nextVal(), // skip CareerLength counter slot
Piercings: *nextVal(),
Aliases: NewRelatedStrings([]string{*nextVal()}),
URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}),

View file

@ -137,7 +137,11 @@ type PerformerFilterType struct {
// Filter by circumcision
Circumcised *CircumcisionCriterionInput `json:"circumcised"`
// Filter by career length
CareerLength *StringCriterionInput `json:"career_length"`
CareerLength *StringCriterionInput `json:"career_length"` // deprecated
// Filter by career start year
CareerStart *IntCriterionInput `json:"career_start"`
// Filter by career end year
CareerEnd *IntCriterionInput `json:"career_end"`
// Filter by tattoos
Tattoos *StringCriterionInput `json:"tattoos"`
// Filter by piercings
@ -224,6 +228,8 @@ type PerformerCreateInput struct {
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
@ -263,6 +269,8 @@ type PerformerUpdateInput struct {
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`

View file

@ -30,7 +30,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
EyeColor: performer.EyeColor,
Measurements: performer.Measurements,
FakeTits: performer.FakeTits,
CareerLength: performer.CareerLength,
Tattoos: performer.Tattoos,
Piercings: performer.Piercings,
Favorite: performer.Favorite,
@ -71,6 +70,13 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
newPerformerJSON.PenisLength = *performer.PenisLength
}
if performer.CareerStart != nil {
newPerformerJSON.CareerStart = performer.CareerStart
}
if performer.CareerEnd != nil {
newPerformerJSON.CareerEnd = performer.CareerEnd
}
if err := performer.LoadAliases(ctx, reader); err != nil {
return nil, fmt.Errorf("loading performer aliases: %w", err)
}

View file

@ -26,7 +26,6 @@ const (
performerName = "testPerformer"
disambiguation = "disambiguation"
url = "url"
careerLength = "careerLength"
country = "country"
ethnicity = "ethnicity"
eyeColor = "eyeColor"
@ -49,6 +48,8 @@ var (
rating = 5
height = 123
weight = 60
careerStart = 2005
careerEnd = 2015
penisLength = 1.23
circumcisedEnum = models.CircumisedEnumCut
circumcised = circumcisedEnum.String()
@ -87,7 +88,8 @@ func createFullPerformer(id int, name string) *models.Performer {
URLs: models.NewRelatedStrings([]string{url, twitter, instagram}),
Aliases: models.NewRelatedStrings(aliases),
Birthdate: &birthDate,
CareerLength: careerLength,
CareerStart: &careerStart,
CareerEnd: &careerEnd,
Country: country,
Ethnicity: ethnicity,
EyeColor: eyeColor,
@ -132,7 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) *
URLs: []string{url, twitter, instagram},
Aliases: aliases,
Birthdate: birthDate.String(),
CareerLength: careerLength,
CareerStart: &careerStart,
CareerEnd: &careerEnd,
Country: country,
Ethnicity: ethnicity,
EyeColor: eyeColor,

View file

@ -32,14 +32,17 @@ type Importer struct {
}
func (i *Importer) PreImport(ctx context.Context) error {
i.performer = performerJSONToPerformer(i.Input)
var err error
i.performer, err = performerJSONToPerformer(i.Input)
if err != nil {
return err
}
i.customFields = i.Input.CustomFields
if err := i.populateTags(ctx); err != nil {
return err
}
var err error
if len(i.Input.Image) > 0 {
i.imageData, err = utils.ProcessBase64Image(i.Input.Image)
if err != nil {
@ -196,7 +199,7 @@ func (i *Importer) Update(ctx context.Context, id int) error {
return nil
}
func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Performer {
func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Performer, error) {
newPerformer := models.Performer{
Name: performerJSON.Name,
Disambiguation: performerJSON.Disambiguation,
@ -205,7 +208,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
EyeColor: performerJSON.EyeColor,
Measurements: performerJSON.Measurements,
FakeTits: performerJSON.FakeTits,
CareerLength: performerJSON.CareerLength,
Tattoos: performerJSON.Tattoos,
Piercings: performerJSON.Piercings,
Aliases: models.NewRelatedStrings(performerJSON.Aliases),
@ -282,5 +284,18 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
}
}
return newPerformer
// prefer explicit career_start/career_end, fall back to parsing legacy career_length
if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil {
newPerformer.CareerStart = performerJSON.CareerStart
newPerformer.CareerEnd = performerJSON.CareerEnd
} else if performerJSON.CareerLength != "" {
start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength)
if err != nil {
return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err)
}
newPerformer.CareerStart = start
newPerformer.CareerEnd = end
}
return newPerformer, nil
}

View file

@ -315,3 +315,86 @@ func TestUpdate(t *testing.T) {
db.AssertExpectations(t)
}
func TestImportCareerFields(t *testing.T) {
startYear := 2005
endYear := 2015
// explicit career_start/career_end should be used directly
t.Run("explicit fields", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerStart: &startYear,
CareerEnd: &endYear,
}
p, err := performerJSONToPerformer(input)
assert.Nil(t, err)
assert.Equal(t, &startYear, p.CareerStart)
assert.Equal(t, &endYear, p.CareerEnd)
})
// explicit fields take priority over legacy career_length
t.Run("explicit fields override legacy", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerStart: &startYear,
CareerEnd: &endYear,
CareerLength: "1990 - 1995",
}
p, err := performerJSONToPerformer(input)
assert.Nil(t, err)
assert.Equal(t, &startYear, p.CareerStart)
assert.Equal(t, &endYear, p.CareerEnd)
})
// legacy career_length should be parsed when explicit fields are absent
t.Run("legacy career_length fallback", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerLength: "2005 - 2015",
}
p, err := performerJSONToPerformer(input)
assert.Nil(t, err)
assert.Equal(t, &startYear, p.CareerStart)
assert.Equal(t, &endYear, p.CareerEnd)
})
// legacy career_length with only start year
t.Run("legacy career_length start only", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerLength: "2005 -",
}
p, err := performerJSONToPerformer(input)
assert.Nil(t, err)
assert.Equal(t, &startYear, p.CareerStart)
assert.Nil(t, p.CareerEnd)
})
// unparseable career_length should return an error
t.Run("legacy career_length unparseable", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerLength: "not a year range",
}
_, err := performerJSONToPerformer(input)
assert.NotNil(t, err)
})
// no career fields at all
t.Run("no career fields", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
}
p, err := performerJSONToPerformer(input)
assert.Nil(t, err)
assert.Nil(t, p.CareerStart)
assert.Nil(t, p.CareerEnd)
})
}

View file

@ -140,6 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer {
PenisLength: r.stringPtr("PenisLength"),
Circumcised: r.stringPtr("Circumcised"),
CareerLength: r.stringPtr("CareerLength"),
CareerStart: r.IntPtr("CareerStart"),
CareerEnd: r.IntPtr("CareerEnd"),
Tattoos: r.stringPtr("Tattoos"),
Piercings: r.stringPtr("Piercings"),
Aliases: r.stringPtr("Aliases"),

View file

@ -20,6 +20,8 @@ type ScrapedPerformerInput struct {
PenisLength *string `json:"penis_length"`
Circumcised *string `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`

View file

@ -125,6 +125,20 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP
}
}
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
isEmptyInt := func(s *int) bool { return s == nil || *s == 0 }
// populate career start/end from career length and vice versa
if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) {
p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength)
if err != nil {
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
}
} else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) {
v := utils.FormatYearRange(p.CareerStart, p.CareerEnd)
p.CareerLength = &v
}
return p, nil
}

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 77
var appSchemaVersion uint = 78
//go:embed migrations/*.sql
var migrationsBox embed.FS

View file

@ -0,0 +1,2 @@
ALTER TABLE "performers" ADD COLUMN "career_start" integer;
ALTER TABLE "performers" ADD COLUMN "career_end" integer;

View file

@ -0,0 +1,143 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
"github.com/stashapp/stash/pkg/utils"
)
type schema78Migrator struct {
migrator
}
func post78(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 78")
m := schema78Migrator{
migrator: migrator{
db: db,
},
}
if err := m.migrateCareerLength(ctx); err != nil {
return fmt.Errorf("migrating career_length: %w", err)
}
if err := m.dropCareerLength(); err != nil {
return fmt.Errorf("dropping career_length column: %w", err)
}
return nil
}
func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error {
logger.Info("Migrating career_length to career_start/career_end")
const limit = 1000
lastID := 0
parsed := 0
unparseable := 0
for {
gotSome := false
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
query := `SELECT id, career_length FROM performers
WHERE career_length IS NOT NULL AND career_length != ''`
if lastID != 0 {
query += fmt.Sprintf(" AND id > %d", lastID)
}
query += fmt.Sprintf(" ORDER BY id LIMIT %d", limit)
rows, err := tx.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
id int
careerLength string
)
if err := rows.Scan(&id, &careerLength); err != nil {
return err
}
lastID = id
gotSome = true
start, end, err := utils.ParseYearRangeString(careerLength)
if err != nil {
logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err)
if err := m.preserveAsCustomField(tx, id, careerLength); err != nil {
return fmt.Errorf("preserving career_length for performer %d: %w", id, err)
}
unparseable++
continue
}
if err := m.updateCareerFields(tx, id, start, end); err != nil {
return fmt.Errorf("updating career fields for performer %d: %w", id, err)
}
parsed++
}
return rows.Err()
}); err != nil {
return err
}
if !gotSome {
break
}
}
logger.Infof("Career length migration complete: %d parsed, %d unparseable (preserved as custom fields)", parsed, unparseable)
return nil
}
func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *int, end *int) error {
_, err := tx.Exec(
"UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?",
start, end, id,
)
return err
}
func (m *schema78Migrator) preserveAsCustomField(tx *sqlx.Tx, id int, value string) error {
// check if a career_length custom field already exists
var existing sql.NullString
err := tx.Get(&existing, "SELECT value FROM performer_custom_fields WHERE performer_id = ? AND field = 'career_length'", id)
if err == nil {
logger.Debugf("career_length custom field already exists for performer %d, skipping", id)
return nil
}
_, err = tx.Exec(
"INSERT INTO performer_custom_fields (performer_id, field, value) VALUES (?, 'career_length', ?)",
id, value,
)
return err
}
func (m *schema78Migrator) dropCareerLength() error {
logger.Info("Dropping career_length column from performers table")
return m.execAll([]string{
"ALTER TABLE performers DROP COLUMN career_length",
})
}
func init() {
sqlite.RegisterPostMigration(78, post78)
}

View file

@ -44,7 +44,8 @@ type performerRow struct {
FakeTits zero.String `db:"fake_tits"`
PenisLength null.Float `db:"penis_length"`
Circumcised zero.String `db:"circumcised"`
CareerLength zero.String `db:"career_length"`
CareerStart null.Int `db:"career_start"`
CareerEnd null.Int `db:"career_end"`
Tattoos zero.String `db:"tattoos"`
Piercings zero.String `db:"piercings"`
Favorite bool `db:"favorite"`
@ -82,7 +83,8 @@ func (r *performerRow) fromPerformer(o models.Performer) {
if o.Circumcised != nil && o.Circumcised.IsValid() {
r.Circumcised = zero.StringFrom(o.Circumcised.String())
}
r.CareerLength = zero.StringFrom(o.CareerLength)
r.CareerStart = intFromPtr(o.CareerStart)
r.CareerEnd = intFromPtr(o.CareerEnd)
r.Tattoos = zero.StringFrom(o.Tattoos)
r.Piercings = zero.StringFrom(o.Piercings)
r.Favorite = o.Favorite
@ -110,7 +112,8 @@ func (r *performerRow) resolve() *models.Performer {
Measurements: r.Measurements.String,
FakeTits: r.FakeTits.String,
PenisLength: nullFloatPtr(r.PenisLength),
CareerLength: r.CareerLength.String,
CareerStart: nullIntPtr(r.CareerStart),
CareerEnd: nullIntPtr(r.CareerEnd),
Tattoos: r.Tattoos.String,
Piercings: r.Piercings.String,
Favorite: r.Favorite,
@ -155,7 +158,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
r.setNullString("fake_tits", o.FakeTits)
r.setNullFloat64("penis_length", o.PenisLength)
r.setNullString("circumcised", o.Circumcised)
r.setNullString("career_length", o.CareerLength)
r.setNullInt("career_start", o.CareerStart)
r.setNullInt("career_end", o.CareerEnd)
r.setNullString("tattoos", o.Tattoos)
r.setNullString("piercings", o.Piercings)
r.setBool("favorite", o.Favorite)
@ -776,7 +780,8 @@ func (qb *PerformerStore) sortByScenesDuration(direction string) string {
var performerSortOptions = sortOptions{
"birthdate",
"career_length",
"career_start",
"career_end",
"created_at",
"galleries_count",
"height",

View file

@ -47,6 +47,29 @@ func (qb *performerFilterHandler) validate() error {
}
}
// if legacy career length filter used, ensure only supported modifiers are used and value is valid
if filter.CareerLength != nil {
careerLength := filter.CareerLength
switch careerLength.Modifier {
case models.CriterionModifierEquals:
start, end, err := utils.ParseYearRangeString(careerLength.Value)
if err != nil {
return fmt.Errorf("invalid career length value: %s", careerLength.Value)
}
// ensure career start/end is not set
if start != nil && filter.CareerStart != nil {
return fmt.Errorf("cannot use legacy CareerLength filter with CareerStart filter")
}
if end != nil && filter.CareerEnd != nil {
return fmt.Errorf("cannot use legacy CareerLength filter with CareerEnd filter")
}
case models.CriterionModifierIsNull, models.CriterionModifierNotNull:
// valid modifiers, no value parsing needed
default:
return fmt.Errorf("invalid career length modifier: %s", careerLength.Modifier)
}
}
return nil
}
@ -71,10 +94,13 @@ func (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder)
}
func (qb *performerFilterHandler) criterionHandler() criterionHandler {
filter := qb.performerFilter
// make a copy of the filter to modify with legacy conversions without affecting original filter used for subfilters
filter := *qb.performerFilter
const tableName = performerTable
heightCmCrit := filter.HeightCm
convertLegacyCareerLengthFilter(&filter)
return compoundHandler{
stringCriterionHandler(filter.Name, tableName+".name"),
stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"),
@ -129,7 +155,9 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
}
}),
stringCriterionHandler(filter.CareerLength, tableName+".career_length"),
// CareerLength filter is deprecated and non-functional (column removed in schema 78)
intCriterionHandler(filter.CareerStart, tableName+".career_start", nil),
intCriterionHandler(filter.CareerEnd, tableName+".career_end", nil),
stringCriterionHandler(filter.Tattoos, tableName+".tattoos"),
stringCriterionHandler(filter.Piercings, tableName+".piercings"),
intCriterionHandler(filter.Rating100, tableName+".rating", nil),
@ -221,6 +249,43 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
}
}
func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) {
// convert legacy career length filter to career start/end filters
if filter.CareerLength != nil {
careerLength := filter.CareerLength
switch careerLength.Modifier {
case models.CriterionModifierEquals:
start, end, _ := utils.ParseYearRangeString(careerLength.Value)
if start != nil {
filter.CareerStart = &models.IntCriterionInput{
Value: (*start) - 1, // minus one to make it exclusive
Modifier: models.CriterionModifierGreaterThan,
}
}
if end != nil {
filter.CareerEnd = &models.IntCriterionInput{
Value: (*end) + 1, // plus one to make it exclusive
Modifier: models.CriterionModifierLessThan,
}
}
case models.CriterionModifierIsNull:
filter.CareerStart = &models.IntCriterionInput{
Modifier: models.CriterionModifierIsNull,
}
filter.CareerEnd = &models.IntCriterionInput{
Modifier: models.CriterionModifierIsNull,
}
case models.CriterionModifierNotNull:
filter.CareerStart = &models.IntCriterionInput{
Modifier: models.CriterionModifierNotNull,
}
filter.CareerEnd = &models.IntCriterionInput{
Modifier: models.CriterionModifierNotNull,
}
}
}
}
// TODO - we need to provide a whitelist of possible values
func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {

View file

@ -66,7 +66,8 @@ func Test_PerformerStore_Create(t *testing.T) {
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
careerStart = 2005
careerEnd = 2015
tattoos = "tattoos"
piercings = "piercings"
aliases = []string{"alias1", "alias2"}
@ -107,7 +108,8 @@ func Test_PerformerStore_Create(t *testing.T) {
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
CareerStart: &careerStart,
CareerEnd: &careerEnd,
Tattoos: tattoos,
Piercings: piercings,
Favorite: favorite,
@ -204,8 +206,6 @@ func Test_PerformerStore_Create(t *testing.T) {
}
assert.Equal(tt.newObject.CustomFields, cf)
return
})
}
}
@ -229,7 +229,8 @@ func Test_PerformerStore_Update(t *testing.T) {
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
careerStart = 2005
careerEnd = 2015
tattoos = "tattoos"
piercings = "piercings"
aliases = []string{"alias1", "alias2"}
@ -271,7 +272,8 @@ func Test_PerformerStore_Update(t *testing.T) {
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
CareerStart: &careerStart,
CareerEnd: &careerEnd,
Tattoos: tattoos,
Piercings: piercings,
Favorite: favorite,
@ -422,7 +424,8 @@ func clearPerformerPartial() models.PerformerPartial {
FakeTits: nullString,
PenisLength: nullFloat,
Circumcised: nullString,
CareerLength: nullString,
CareerStart: nullInt,
CareerEnd: nullInt,
Tattoos: nullString,
Piercings: nullString,
Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
@ -455,7 +458,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
fakeTits = "fakeTits"
penisLength = 1.23
circumcised = models.CircumisedEnumCut
careerLength = "careerLength"
careerStart = 2005
careerEnd = 2015
tattoos = "tattoos"
piercings = "piercings"
aliases = []string{"alias1", "alias2"}
@ -501,7 +505,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
FakeTits: models.NewOptionalString(fakeTits),
PenisLength: models.NewOptionalFloat64(penisLength),
Circumcised: models.NewOptionalString(circumcised.String()),
CareerLength: models.NewOptionalString(careerLength),
CareerStart: models.NewOptionalInt(careerStart),
CareerEnd: models.NewOptionalInt(careerEnd),
Tattoos: models.NewOptionalString(tattoos),
Piercings: models.NewOptionalString(piercings),
Aliases: &models.UpdateStrings{
@ -552,7 +557,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
FakeTits: fakeTits,
PenisLength: &penisLength,
Circumcised: &circumcised,
CareerLength: careerLength,
CareerStart: &careerStart,
CareerEnd: &careerEnd,
Tattoos: tattoos,
Piercings: piercings,
Aliases: models.NewRelatedStrings(aliases),
@ -1766,30 +1772,117 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) {
})
}
func TestPerformerQueryCareerLength(t *testing.T) {
const value = "2005"
careerLengthCriterion := models.StringCriterionInput{
func TestPerformerQueryLegacyCareerLength(t *testing.T) {
const value = "2002 - 2012"
tests := []struct {
name string
c models.StringCriterionInput
careerStartCrit *models.IntCriterionInput
careerEndCrit *models.IntCriterionInput
err bool
}{
{
name: "valid format",
c: models.StringCriterionInput{
Value: value,
Modifier: models.CriterionModifierEquals,
},
careerStartCrit: &models.IntCriterionInput{
Value: 2002,
Modifier: models.CriterionModifierEquals,
},
careerEndCrit: &models.IntCriterionInput{
Value: 2012,
Modifier: models.CriterionModifierEquals,
},
err: false,
},
{
name: "invalid format",
c: models.StringCriterionInput{
Value: "invalid format",
Modifier: models.CriterionModifierEquals,
},
err: true,
},
{
name: "is null",
c: models.StringCriterionInput{
Modifier: models.CriterionModifierIsNull,
},
careerStartCrit: &models.IntCriterionInput{
Modifier: models.CriterionModifierIsNull,
},
careerEndCrit: &models.IntCriterionInput{
Modifier: models.CriterionModifierIsNull,
},
err: false,
},
{
name: "not null",
c: models.StringCriterionInput{
Modifier: models.CriterionModifierNotNull,
},
careerStartCrit: &models.IntCriterionInput{
Modifier: models.CriterionModifierNotNull,
},
careerEndCrit: &models.IntCriterionInput{
Modifier: models.CriterionModifierNotNull,
},
err: false,
},
{
name: "invalid modifier",
c: models.StringCriterionInput{
Value: value,
Modifier: models.CriterionModifierMatchesRegex,
},
err: true,
},
}
qb := db.Performer
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
performers, _, err := qb.Query(ctx, &models.PerformerFilterType{
CareerLength: &tt.c,
}, nil)
if err != nil && !tt.err {
t.Errorf("Error querying performer: %s", err.Error())
} else if err == nil && tt.err {
t.Errorf("Expected error but got none")
}
if err != nil || tt.err {
return
}
if len(performers) == 0 {
t.Errorf("Expected to find performers but found none")
}
for _, performer := range performers {
verifyIntPtr(t, performer.CareerStart, *tt.careerStartCrit)
verifyIntPtr(t, performer.CareerEnd, *tt.careerEndCrit)
}
})
}
}
func TestPerformerQueryCareerStart(t *testing.T) {
const value = 2002
criterion := models.IntCriterionInput{
Value: value,
Modifier: models.CriterionModifierEquals,
}
verifyPerformerCareerLength(t, careerLengthCriterion)
careerLengthCriterion.Modifier = models.CriterionModifierNotEquals
verifyPerformerCareerLength(t, careerLengthCriterion)
careerLengthCriterion.Modifier = models.CriterionModifierMatchesRegex
verifyPerformerCareerLength(t, careerLengthCriterion)
careerLengthCriterion.Modifier = models.CriterionModifierNotMatchesRegex
verifyPerformerCareerLength(t, careerLengthCriterion)
}
func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Performer
performerFilter := models.PerformerFilterType{
CareerLength: &criterion,
CareerStart: &criterion,
}
performers, _, err := qb.Query(ctx, &performerFilter, nil)
@ -1798,8 +1891,33 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI
}
for _, performer := range performers {
cl := performer.CareerLength
verifyString(t, cl, criterion)
verifyIntPtr(t, performer.CareerStart, criterion)
}
return nil
})
}
func TestPerformerQueryCareerEnd(t *testing.T) {
const value = 2012
criterion := models.IntCriterionInput{
Value: value,
Modifier: models.CriterionModifierEquals,
}
withTxn(func(ctx context.Context) error {
qb := db.Performer
performerFilter := models.PerformerFilterType{
CareerEnd: &criterion,
}
performers, _, err := qb.Query(ctx, &performerFilter, nil)
if err != nil {
t.Errorf("Error querying performer: %s", err.Error())
}
for _, performer := range performers {
verifyIntPtr(t, performer.CareerEnd, criterion)
}
return nil

View file

@ -1513,15 +1513,28 @@ func getPerformerDeathDate(index int) *models.Date {
return &ret
}
func getPerformerCareerLength(index int) *string {
func getPerformerCareerStart(index int) *int {
if index%5 == 0 {
return nil
}
ret := fmt.Sprintf("20%2d", index)
ret := 2000 + index
return &ret
}
func getPerformerCareerEnd(index int) *int {
if index%5 == 0 {
return nil
}
// only set career_end for even indices
if index%2 == 0 {
ret := 2010 + index
return &ret
}
return nil
}
func getPerformerPenisLength(index int) *float64 {
if index%5 == 0 {
return nil
@ -1615,10 +1628,8 @@ func createPerformers(ctx context.Context, n int, o int) error {
TagIDs: models.NewRelatedIDs(tids),
}
careerLength := getPerformerCareerLength(i)
if careerLength != nil {
performer.CareerLength = *careerLength
}
performer.CareerStart = getPerformerCareerStart(i)
performer.CareerEnd = getPerformerCareerEnd(i)
if (index+1)%5 != 0 {
performer.StashIDs = models.NewRelatedStashIDs([]models.StashID{

View file

@ -231,6 +231,16 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc
sp.Height = &hs
}
if p.CareerStartYear != nil {
cs := *p.CareerStartYear
sp.CareerStart = &cs
}
if p.CareerEndYear != nil {
ce := *p.CareerEndYear
sp.CareerEnd = &ce
}
if p.BirthDate != nil {
sp.Birthdate = padFuzzyDate(p.BirthDate)
}
@ -388,16 +398,11 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
aliases := strings.Join(performer.Aliases.List(), ",")
draft.Aliases = &aliases
}
if performer.CareerLength != "" {
var career = strings.Split(performer.CareerLength, "-")
if i, err := strconv.Atoi(strings.TrimSpace(career[0])); err == nil {
draft.CareerStartYear = &i
}
if len(career) == 2 {
if y, err := strconv.Atoi(strings.TrimSpace(career[1])); err == nil {
draft.CareerEndYear = &y
}
}
if performer.CareerStart != nil {
draft.CareerStartYear = performer.CareerStart
}
if performer.CareerEnd != nil {
draft.CareerEndYear = performer.CareerEnd
}
if len(performer.URLs.List()) > 0 {

View file

@ -2,6 +2,8 @@ package utils
import (
"fmt"
"strconv"
"strings"
"time"
)
@ -25,3 +27,80 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) {
return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString)
}
// ParseYearRangeString parses a year range string into start and end year integers.
// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present".
// Returns nil for start/end if not present in the string.
func ParseYearRangeString(s string) (start *int, end *int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil, fmt.Errorf("empty year range string")
}
// normalize "present" to empty end
lower := strings.ToLower(s)
lower = strings.ReplaceAll(lower, "present", "")
// split on "-" if it contains one
var parts []string
if strings.Contains(lower, "-") {
parts = strings.SplitN(lower, "-", 2)
} else {
// single value, treat as start year
year, err := parseYear(lower)
if err != nil {
return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err)
}
return &year, nil, nil
}
startStr := strings.TrimSpace(parts[0])
endStr := strings.TrimSpace(parts[1])
if startStr != "" {
y, err := parseYear(startStr)
if err != nil {
return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err)
}
start = &y
}
if endStr != "" {
y, err := parseYear(endStr)
if err != nil {
return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err)
}
end = &y
}
if start == nil && end == nil {
return nil, nil, fmt.Errorf("could not parse year range %q", s)
}
return start, end, nil
}
func parseYear(s string) (int, error) {
s = strings.TrimSpace(s)
year, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid year %q: %w", s, err)
}
if year < 1900 || year > 2200 {
return 0, fmt.Errorf("year %d out of reasonable range", year)
}
return year, nil
}
func FormatYearRange(start *int, end *int) string {
switch {
case start == nil && end == nil:
return ""
case end == nil:
return fmt.Sprintf("%d -", *start)
case start == nil:
return fmt.Sprintf("- %d", *end)
default:
return fmt.Sprintf("%d - %d", *start, *end)
}
}

View file

@ -2,6 +2,8 @@ package utils
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseDateStringAsTime(t *testing.T) {
@ -41,3 +43,66 @@ func TestParseDateStringAsTime(t *testing.T) {
})
}
}
func TestParseYearRangeString(t *testing.T) {
intPtr := func(v int) *int { return &v }
tests := []struct {
name string
input string
wantStart *int
wantEnd *int
wantErr bool
}{
{"single year", "2005", intPtr(2005), nil, false},
{"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false},
{"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false},
{"year dash open", "2005 -", intPtr(2005), nil, false},
{"year dash open no space", "2005-", intPtr(2005), nil, false},
{"dash year", "- 2010", nil, intPtr(2010), false},
{"year present", "2005-present", intPtr(2005), nil, false},
{"year Present caps", "2005 - Present", intPtr(2005), nil, false},
{"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false},
{"empty string", "", nil, nil, true},
{"garbage", "not a year", nil, nil, true},
{"partial garbage start", "abc - 2010", nil, nil, true},
{"partial garbage end", "2005 - abc", nil, nil, true},
{"year out of range", "1800", nil, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := ParseYearRangeString(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantStart, start)
assert.Equal(t, tt.wantEnd, end)
})
}
}
func TestFormatYearRange(t *testing.T) {
intPtr := func(v int) *int { return &v }
tests := []struct {
name string
start *int
end *int
want string
}{
{"both nil", nil, nil, ""},
{"only start", intPtr(2005), nil, "2005 -"},
{"only end", nil, intPtr(2010), "- 2010"},
{"start and end", intPtr(2005), intPtr(2010), "2005 - 2010"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatYearRange(tt.start, tt.end)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -16,7 +16,8 @@ fragment SlimPerformerData on Performer {
fake_tits
penis_length
circumcised
career_length
career_start
career_end
tattoos
piercings
alias_list

View file

@ -13,7 +13,8 @@ fragment PerformerData on Performer {
fake_tits
penis_length
circumcised
career_length
career_start
career_end
tattoos
piercings
alias_list

View file

@ -38,7 +38,8 @@ fragment ScrapedPerformerData on ScrapedPerformer {
fake_tits
penis_length
circumcised
career_length
career_start
career_end
tattoos
piercings
aliases
@ -68,7 +69,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer {
fake_tits
penis_length
circumcised
career_length
career_start
career_end
tattoos
piercings
aliases

View file

@ -42,7 +42,8 @@ const performerFields = [
"gender",
"birthdate",
"death_date",
"career_length",
"career_start",
"career_end",
"country",
"ethnicity",
"eye_color",
@ -363,8 +364,15 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
{renderTextField("piercings", updateInput.piercings, (v) =>
setUpdateField({ piercings: v })
)}
{renderTextField("career_length", updateInput.career_length, (v) =>
setUpdateField({ career_length: v })
{renderTextField(
"career_start",
updateInput.career_start?.toString(),
(v) => setUpdateField({ career_start: v ? parseInt(v) : undefined })
)}
{renderTextField(
"career_end",
updateInput.career_end?.toString(),
(v) => setUpdateField({ career_end: v ? parseInt(v) : undefined })
)}
<Form.Group controlId="tags">

View file

@ -12,6 +12,7 @@ import {
FormatHeight,
FormatPenisLength,
FormatWeight,
formatYearRange,
} from "../PerformerList";
import { PatchComponent } from "src/patch";
import { CustomFields } from "src/components/Shared/CustomFields";
@ -174,7 +175,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
/>
<DetailItem
id="career_length"
value={performer?.career_length}
value={formatYearRange(
performer?.career_start,
performer?.career_end
)}
fullWidth={fullWidth}
/>
<DetailItem id="details" value={details} fullWidth={fullWidth} />

View file

@ -126,7 +126,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
career_length: yup.string().ensure(),
career_start: yupInputNumber().positive().nullable().defined(),
career_end: yupInputNumber().positive().nullable().defined(),
urls: yupUniqueStringList(intl),
details: yup.string().ensure(),
tag_ids: yup.array(yup.string().required()).defined(),
@ -155,7 +156,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
circumcised: performer.circumcised ?? null,
tattoos: performer.tattoos ?? "",
piercings: performer.piercings ?? "",
career_length: performer.career_length ?? "",
career_start: performer.career_start ?? null,
career_end: performer.career_end ?? null,
urls: performer.urls ?? [],
details: performer.details ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
@ -256,8 +258,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
if (state.fake_tits) {
formik.setFieldValue("fake_tits", state.fake_tits);
}
if (state.career_length) {
formik.setFieldValue("career_length", state.career_length);
if (state.career_start) {
formik.setFieldValue("career_start", state.career_start);
}
if (state.career_end) {
formik.setFieldValue("career_end", state.career_end);
}
if (state.tattoos) {
formik.setFieldValue("tattoos", state.tattoos);
@ -747,7 +752,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderInputField("tattoos", "textarea")}
{renderInputField("piercings", "textarea")}
{renderInputField("career_length")}
{renderInputField("career_start", "number")}
{renderInputField("career_end", "number")}
{renderURLListField("urls", onScrapePerformerURL, urlScrapable)}

View file

@ -8,6 +8,7 @@ import {
ScrapedTextAreaRow,
ScrapedCountryRow,
ScrapedStringListRow,
ScrapedNumberRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { Form } from "react-bootstrap";
@ -272,10 +273,16 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.fake_tits, props.scraped.fake_tits)
);
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.career_length,
props.scraped.career_length
const [careerStart, setCareerStart] = useState<ScrapeResult<number>>(
new ScrapeResult<number>(
props.performer.career_start,
props.scraped.career_start
)
);
const [careerEnd, setCareerEnd] = useState<ScrapeResult<number>>(
new ScrapeResult<number>(
props.performer.career_end,
props.scraped.career_end
)
);
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
@ -347,7 +354,8 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
fakeTits,
penisLength,
circumcised,
careerLength,
careerStart,
careerEnd,
tattoos,
piercings,
urls,
@ -379,7 +387,8 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
height: height.getNewValue(),
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_length: careerLength.getNewValue(),
career_start: careerStart.getNewValue(),
career_end: careerEnd.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
@ -493,11 +502,17 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
field="career_length"
title={intl.formatMessage({ id: "career_length" })}
result={careerLength}
onChange={(value) => setCareerLength(value)}
<ScrapedNumberRow
field="career_start"
title={intl.formatMessage({ id: "career_start" })}
result={careerStart}
onChange={(value) => setCareerStart(value)}
/>
<ScrapedNumberRow
field="career_end"
title={intl.formatMessage({ id: "career_end" })}
result={careerEnd}
onChange={(value) => setCareerEnd(value)}
/>
<ScrapedTextAreaRow
field="tattoos"

View file

@ -137,6 +137,14 @@ export const FormatWeight = (weight?: number | null) => {
);
};
export function formatYearRange(
start?: number | null,
end?: number | null
): string | undefined {
if (!start && !end) return undefined;
return `${start ?? ""} - ${end ?? ""}`;
}
export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
const intl = useIntl();
if (!circumcised) {

View file

@ -17,6 +17,7 @@ import {
FormatHeight,
FormatPenisLength,
FormatWeight,
formatYearRange,
} from "./PerformerList";
import TextUtils from "src/utils/text";
import { getCountryByISO } from "src/utils/country";
@ -188,7 +189,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
);
const CareerLengthCell = (performer: GQL.PerformerDataFragment) => (
<span className="ellips-data">{performer.career_length}</span>
<>{formatYearRange(performer.career_start, performer.career_end) ?? ""}</>
);
const SceneCountCell = (performer: GQL.PerformerDataFragment) => (

View file

@ -102,8 +102,11 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.fake_tits)
);
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.career_length)
const [careerStart, setCareerStart] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.career_start?.toString())
);
const [careerEnd, setCareerEnd] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.career_end?.toString())
);
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.tattoos)
@ -264,11 +267,18 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
!dest.fake_tits
)
);
setCareerLength(
setCareerStart(
new ScrapeResult(
dest.career_length,
sources.find((s) => s.career_length)?.career_length,
!dest.career_length
dest.career_start?.toString(),
sources.find((s) => s.career_start)?.career_start?.toString(),
!dest.career_start
)
);
setCareerEnd(
new ScrapeResult(
dest.career_end?.toString(),
sources.find((s) => s.career_end)?.career_end?.toString(),
!dest.career_end
)
);
setTattoos(
@ -378,7 +388,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
penisLength,
measurements,
fakeTits,
careerLength,
careerStart,
careerEnd,
tattoos,
piercings,
urls,
@ -404,7 +415,8 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
penisLength,
measurements,
fakeTits,
careerLength,
careerStart,
careerEnd,
tattoos,
piercings,
urls,
@ -520,10 +532,16 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
field="career_length"
title={intl.formatMessage({ id: "career_length" })}
result={careerLength}
onChange={(value) => setCareerLength(value)}
field="career_start"
title={intl.formatMessage({ id: "career_start" })}
result={careerStart}
onChange={(value) => setCareerStart(value)}
/>
<ScrapedInputGroupRow
field="career_end"
title={intl.formatMessage({ id: "career_end" })}
result={careerEnd}
onChange={(value) => setCareerEnd(value)}
/>
<ScrapedTextAreaRow
field="tattoos"
@ -612,7 +630,12 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
: undefined,
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_length: careerLength.getNewValue(),
career_start: careerStart.getNewValue()
? parseInt(careerStart.getNewValue()!)
: undefined,
career_end: careerEnd.getNewValue()
? parseInt(careerEnd.getNewValue()!)
: undefined,
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),

View file

@ -68,7 +68,8 @@
.collapsed {
.detail-item.tattoos,
.detail-item.piercings,
.detail-item.career_length,
.detail-item.career_start,
.detail-item.career_end,
.detail-item.details,
.detail-item.tags,
.detail-item.stash_ids {

View file

@ -171,6 +171,70 @@ export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
);
};
interface IScrapedNumberInputProps {
isNew?: boolean;
placeholder?: string;
locked?: boolean;
result: ScrapeResult<number>;
onChange?: (value: number) => void;
}
const ScrapedNumberInput: React.FC<IScrapedNumberInputProps> = (props) => {
return (
<FormControl
placeholder={props.placeholder}
value={props.isNew ? props.result.newValue : props.result.originalValue}
readOnly={!props.isNew || props.locked}
onChange={(e) => {
if (props.isNew && props.onChange) {
props.onChange(Number(e.target.value));
}
}}
className="bg-secondary text-white border-secondary"
type="number"
/>
);
};
interface IScrapedNumberRowProps {
title: string;
field: string;
className?: string;
placeholder?: string;
result: ScrapeResult<number>;
locked?: boolean;
onChange: (value: ScrapeResult<number>) => void;
}
export const ScrapedNumberRow: React.FC<IScrapedNumberRowProps> = (props) => {
return (
<ScrapeDialogRow
title={props.title}
field={props.field}
className={props.className}
result={props.result}
originalField={
<ScrapedNumberInput
placeholder={props.placeholder || props.title}
result={props.result}
/>
}
newField={
<ScrapedNumberInput
placeholder={props.placeholder || props.title}
result={props.result}
isNew
locked={props.locked}
onChange={(value) =>
props.onChange(props.result.cloneWithValue(value))
}
/>
}
onChange={props.onChange}
/>
);
};
interface IScrapedStringListProps {
isNew?: boolean;
placeholder?: string;

View file

@ -240,7 +240,8 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
height_cm: Number.parseFloat(performer.height ?? "") ?? undefined,
measurements: performer.measurements,
fake_tits: performer.fake_tits,
career_length: performer.career_length,
career_start: performer.career_start,
career_end: performer.career_end,
tattoos: performer.tattoos,
piercings: performer.piercings,
urls: performer.urls,
@ -326,7 +327,8 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
{maybeRenderField("measurements", performer.measurements)}
{performer?.gender !== GQL.GenderEnum.Male &&
maybeRenderField("fake_tits", performer.fake_tits)}
{maybeRenderField("career_length", performer.career_length)}
{maybeRenderField("career_start", performer.career_start?.toString())}
{maybeRenderField("career_end", performer.career_end?.toString())}
{maybeRenderField("tattoos", performer.tattoos, false)}
{maybeRenderField("piercings", performer.piercings, false)}
{maybeRenderField("weight", performer.weight, false)}

View file

@ -75,7 +75,8 @@ export const PERFORMER_FIELDS = [
"fake_tits",
"tattoos",
"piercings",
"career_length",
"career_start",
"career_end",
"urls",
"details",
];

View file

@ -104,7 +104,10 @@ export const scrapedPerformerToCreateInput = (
height_cm: toCreate.height ? Number(toCreate.height) : undefined,
measurements: toCreate.measurements,
fake_tits: toCreate.fake_tits,
career_length: toCreate.career_length,
career_start: toCreate.career_start
? Number(toCreate.career_start)
: undefined,
career_end: toCreate.career_end ? Number(toCreate.career_end) : undefined,
tattoos: toCreate.tattoos,
piercings: toCreate.piercings,
alias_list: aliases,

View file

@ -175,7 +175,9 @@
"filesystem": "Filesystem"
},
"captions": "Captions",
"career_end": "Career End",
"career_length": "Career Length",
"career_start": "Career Start",
"chapters": "Chapters",
"circumcised": "Circumcised",
"circumcised_types": {

View file

@ -58,7 +58,8 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
"weight",
"measurements",
"fake_tits",
"career_length",
"career_start",
"career_end",
"tattoos",
"piercings",
"aliases",

View file

@ -32,7 +32,8 @@ const sortByOptions = [
"play_count",
"last_played_at",
"latest_scene",
"career_length",
"career_start",
"career_end",
"weight",
"measurements",
"scenes_duration",
@ -75,6 +76,8 @@ const numberCriteria: CriterionType[] = [
"age",
"weight",
"penis_length",
"career_start",
"career_end",
];
const stringCriteria: CriterionType[] = [
@ -86,7 +89,6 @@ const stringCriteria: CriterionType[] = [
"eye_color",
"measurements",
"fake_tits",
"career_length",
"tattoos",
"piercings",
"aliases",

View file

@ -166,6 +166,8 @@ export type CriterionType =
| "penis_length"
| "circumcised"
| "career_length"
| "career_start"
| "career_end"
| "tattoos"
| "piercings"
| "aliases"