Stash rating system (#2830)

* add rating100 fields to represent rating range 1-100
* deprecate existing (1-5) rating fields
* add half- and quarter-star options for rating system
* add decimal rating system option

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
skier233
2022-11-15 17:31:44 -05:00
committed by GitHub
parent f66333bac9
commit 7eae751d1c
133 changed files with 2192 additions and 761 deletions

6
.gitignore vendored
View File

@@ -23,6 +23,12 @@ ui/v2.5/src/core/generated-*.tsx
# Jetbrains # Jetbrains
#### ####
####
# Visual Studio
####
/.vs
# User-specific stuff # User-specific stuff
.idea/**/workspace.xml .idea/**/workspace.xml
.idea/**/tasks.xml .idea/**/tasks.xml

View File

@@ -4,7 +4,7 @@ fragment SlimGalleryData on Gallery {
date date
url url
details details
rating rating100
organized organized
files { files {
...GalleryFileData ...GalleryFileData

View File

@@ -6,7 +6,7 @@ fragment GalleryData on Gallery {
date date
url url
details details
rating rating100
organized organized
files { files {

View File

@@ -1,7 +1,7 @@
fragment SlimImageData on Image { fragment SlimImageData on Image {
id id
title title
rating rating100
organized organized
o_counter o_counter

View File

@@ -1,7 +1,7 @@
fragment ImageData on Image { fragment ImageData on Image {
id id
title title
rating rating100
organized organized
o_counter o_counter
created_at created_at

View File

@@ -2,4 +2,5 @@ fragment SlimMovieData on Movie {
id id
name name
front_image_path front_image_path
rating100
} }

View File

@@ -5,7 +5,7 @@ fragment MovieData on Movie {
aliases aliases
duration duration
date date
rating rating100
director director
studio { studio {

View File

@@ -26,7 +26,7 @@ fragment SlimPerformerData on Performer {
endpoint endpoint
stash_id stash_id
} }
rating rating100
death_date death_date
weight weight
} }

View File

@@ -33,7 +33,7 @@ fragment PerformerData on Performer {
stash_id stash_id
endpoint endpoint
} }
rating rating100
details details
death_date death_date
hair_color hair_color

View File

@@ -6,7 +6,7 @@ fragment SlimSceneData on Scene {
director director
url url
date date
rating rating100
o_counter o_counter
organized organized
interactive interactive

View File

@@ -6,7 +6,7 @@ fragment SceneData on Scene {
director director
url url
date date
rating rating100
o_counter o_counter
organized organized
interactive interactive

View File

@@ -10,6 +10,6 @@ fragment SlimStudioData on Studio {
id id
} }
details details
rating rating100
aliases aliases
} }

View File

@@ -25,6 +25,6 @@ fragment StudioData on Studio {
endpoint endpoint
} }
details details
rating rating100
aliases aliases
} }

View File

@@ -92,7 +92,9 @@ input PerformerFilterType {
"""Filter by StashID""" """Filter by StashID"""
stash_id: StringCriterionInput stash_id: StringCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by url""" """Filter by url"""
url: StringCriterionInput url: StringCriterionInput
"""Filter by hair color""" """Filter by hair color"""
@@ -158,7 +160,9 @@ input SceneFilterType {
"""Filter by file count""" """Filter by file count"""
file_count: IntCriterionInput file_count: IntCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized""" """Filter by organized"""
organized: Boolean organized: Boolean
"""Filter by o-counter""" """Filter by o-counter"""
@@ -218,7 +222,9 @@ input MovieFilterType {
"""Filter by duration (in seconds)""" """Filter by duration (in seconds)"""
duration: IntCriterionInput duration: IntCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter to only include movies with this studio""" """Filter to only include movies with this studio"""
studios: HierarchicalMultiCriterionInput studios: HierarchicalMultiCriterionInput
"""Filter to only include movies missing this property""" """Filter to only include movies missing this property"""
@@ -249,7 +255,9 @@ input StudioFilterType {
"""Filter to only include studios missing this property""" """Filter to only include studios missing this property"""
is_missing: String is_missing: String
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by scene count""" """Filter by scene count"""
scene_count: IntCriterionInput scene_count: IntCriterionInput
"""Filter by image count""" """Filter by image count"""
@@ -288,7 +296,9 @@ input GalleryFilterType {
"""Filter to include/exclude galleries that were created from zip""" """Filter to include/exclude galleries that were created from zip"""
is_zip: Boolean is_zip: Boolean
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized""" """Filter by organized"""
organized: Boolean organized: Boolean
"""Filter by average image resolution""" """Filter by average image resolution"""
@@ -391,7 +401,9 @@ input ImageFilterType {
"""Filter by file count""" """Filter by file count"""
file_count: IntCriterionInput file_count: IntCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized""" """Filter by organized"""
organized: Boolean organized: Boolean
"""Filter by o-counter""" """Filter by o-counter"""

View File

@@ -7,7 +7,10 @@ type Gallery {
url: String url: String
date: String date: String
details: String details: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean! organized: Boolean!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@@ -32,7 +35,10 @@ input GalleryCreateInput {
url: String url: String
date: String date: String
details: String details: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
scene_ids: [ID!] scene_ids: [ID!]
studio_id: ID studio_id: ID
@@ -47,7 +53,10 @@ input GalleryUpdateInput {
url: String url: String
date: String date: String
details: String details: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
scene_ids: [ID!] scene_ids: [ID!]
studio_id: ID studio_id: ID
@@ -63,7 +72,10 @@ input BulkGalleryUpdateInput {
url: String url: String
date: String date: String
details: String details: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
scene_ids: BulkUpdateIds scene_ids: BulkUpdateIds
studio_id: ID studio_id: ID

View File

@@ -2,7 +2,10 @@ type Image {
id: ID! id: ID!
checksum: String @deprecated(reason: "Use files.fingerprints") checksum: String @deprecated(reason: "Use files.fingerprints")
title: String title: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
o_counter: Int o_counter: Int
organized: Boolean! organized: Boolean!
path: String! @deprecated(reason: "Use files.path") path: String! @deprecated(reason: "Use files.path")
@@ -37,7 +40,10 @@ input ImageUpdateInput {
clientMutationId: String clientMutationId: String
id: ID! id: ID!
title: String title: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID
@@ -52,7 +58,10 @@ input BulkImageUpdateInput {
clientMutationId: String clientMutationId: String
ids: [ID!] ids: [ID!]
title: String title: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID

View File

@@ -6,7 +6,10 @@ type Movie {
"""Duration in seconds""" """Duration in seconds"""
duration: Int duration: Int
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio: Studio studio: Studio
director: String director: String
synopsis: String synopsis: String
@@ -26,7 +29,10 @@ input MovieCreateInput {
"""Duration in seconds""" """Duration in seconds"""
duration: Int duration: Int
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID studio_id: ID
director: String director: String
synopsis: String synopsis: String
@@ -43,7 +49,10 @@ input MovieUpdateInput {
aliases: String aliases: String
duration: Int duration: Int
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID studio_id: ID
director: String director: String
synopsis: String synopsis: String
@@ -57,7 +66,10 @@ input MovieUpdateInput {
input BulkMovieUpdateInput { input BulkMovieUpdateInput {
clientMutationId: String clientMutationId: String
ids: [ID!] ids: [ID!]
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID studio_id: ID
director: String director: String
} }

View File

@@ -37,7 +37,10 @@ type Performer {
gallery_count: Int # Resolver gallery_count: Int # Resolver
scenes: [Scene!]! scenes: [Scene!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -72,7 +75,10 @@ input PerformerCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -105,7 +111,10 @@ input PerformerUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -135,7 +144,10 @@ input BulkPerformerUpdateInput {
instagram: String instagram: String
favorite: Boolean favorite: Boolean
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
death_date: String death_date: String
hair_color: String hair_color: String

View File

@@ -41,7 +41,10 @@ type Scene {
director: String director: String
url: String url: String
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean! organized: Boolean!
o_counter: Int o_counter: Int
path: String! @deprecated(reason: "Use files.path") path: String! @deprecated(reason: "Use files.path")
@@ -106,7 +109,10 @@ input SceneUpdateInput {
director: String director: String
url: String url: String
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
o_counter: Int o_counter: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID
@@ -141,7 +147,10 @@ input BulkSceneUpdateInput {
director: String director: String
url: String url: String
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID
gallery_ids: BulkUpdateIds gallery_ids: BulkUpdateIds
@@ -191,7 +200,10 @@ type SceneParserResult {
director: String director: String
url: String url: String
date: String date: String
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID studio_id: ID
gallery_ids: [ID!] gallery_ids: [ID!]
performer_ids: [ID!] performer_ids: [ID!]

View File

@@ -13,7 +13,10 @@ type Studio {
image_count: Int # Resolver image_count: Int # Resolver
gallery_count: Int # Resolver gallery_count: Int # Resolver
stash_ids: [StashID!]! stash_ids: [StashID!]!
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@@ -28,7 +31,10 @@ input StudioCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
@@ -42,7 +48,10 @@ input StudioUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
rating: Int # rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String details: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean

View File

@@ -189,6 +189,36 @@ func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64
return ret return ret
} }
func (t changesetTranslator) ratingConversion(legacyValue *int, rating100Value *int) *sql.NullInt64 {
const (
legacyField = "rating"
rating100Field = "rating100"
)
legacyRating := t.nullInt64(legacyValue, legacyField)
if legacyRating != nil {
if legacyRating.Valid {
legacyRating.Int64 = int64(models.Rating5To100(int(legacyRating.Int64)))
}
return legacyRating
}
return t.nullInt64(rating100Value, rating100Field)
}
func (t changesetTranslator) ratingConversionOptional(legacyValue *int, rating100Value *int) models.OptionalInt {
const (
legacyField = "rating"
rating100Field = "rating100"
)
legacyRating := t.optionalInt(legacyValue, legacyField)
if legacyRating.Set && !(legacyRating.Null) {
legacyRating.Value = int(models.Rating5To100(int(legacyRating.Value)))
return legacyRating
}
return t.optionalInt(rating100Value, rating100Field)
}
func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt { func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt {
if !t.hasField(field) { if !t.hasField(field) {
return models.OptionalInt{} return models.OptionalInt{}

View File

@@ -189,6 +189,18 @@ func (r *galleryResolver) Checksum(ctx context.Context, obj *models.Gallery) (st
return obj.PrimaryChecksum(), nil return obj.PrimaryChecksum(), nil
} }
func (r *galleryResolver) Rating(ctx context.Context, obj *models.Gallery) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *galleryResolver) Rating100(ctx context.Context, obj *models.Gallery) (*int, error) {
return obj.Rating, nil
}
func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) { func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) {
if !obj.SceneIDs.Loaded() { if !obj.SceneIDs.Loaded() {
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {

View File

@@ -144,6 +144,18 @@ func (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret [
return ret, firstError(errs) return ret, firstError(errs)
} }
func (r *imageResolver) Rating(ctx context.Context, obj *models.Image) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *imageResolver) Rating100(ctx context.Context, obj *models.Image) (*int, error) {
return obj.Rating, nil
}
func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) { func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) {
if obj.StudioID == nil { if obj.StudioID == nil {
return nil, nil return nil, nil

View File

@@ -48,6 +48,14 @@ func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, e
} }
func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*int, error) { func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Rating.Valid {
rating := models.Rating100To5(int(obj.Rating.Int64))
return &rating, nil
}
return nil, nil
}
func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Rating.Valid { if obj.Rating.Valid {
rating := int(obj.Rating.Int64) rating := int(obj.Rating.Int64)
return &rating, nil return &rating, nil

View File

@@ -107,6 +107,18 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
return stashIDsSliceToPtrSlice(ret), nil return stashIDsSliceToPtrSlice(ret), nil
} }
func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *performerResolver) Rating100(ctx context.Context, obj *models.Performer) (*int, error) {
return obj.Rating, nil
}
func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) { func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.DeathDate != nil { if obj.DeathDate != nil {
ret := obj.DeathDate.String() ret := obj.DeathDate.String()

View File

@@ -141,6 +141,18 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF
return ret, nil return ret, nil
} }
func (r *sceneResolver) Rating(ctx context.Context, obj *models.Scene) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *sceneResolver) Rating100(ctx context.Context, obj *models.Scene) (*int, error) {
return obj.Rating, nil
}
func resolveFingerprints(f *file.BaseFile) []*Fingerprint { func resolveFingerprints(f *file.BaseFile) []*Fingerprint {
ret := make([]*Fingerprint, len(f.Fingerprints)) ret := make([]*Fingerprint, len(f.Fingerprints))

View File

@@ -126,6 +126,14 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*m
} }
func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) { func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) {
if obj.Rating.Valid {
rating := models.Rating100To5(int(obj.Rating.Int64))
return &rating, nil
}
return nil, nil
}
func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*int, error) {
if obj.Rating.Valid { if obj.Rating.Valid {
rating := int(obj.Rating.Int64) rating := int(obj.Rating.Int64)
return &rating, nil return &rating, nil

View File

@@ -68,7 +68,13 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
d := models.NewDate(*input.Date) d := models.NewDate(*input.Date)
newGallery.Date = &d newGallery.Date = &d
} }
newGallery.Rating = input.Rating
if input.Rating100 != nil {
newGallery.Rating = input.Rating100
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newGallery.Rating = &rating
}
if input.StudioID != nil { if input.StudioID != nil {
studioID, _ := strconv.Atoi(*input.StudioID) studioID, _ := strconv.Atoi(*input.StudioID)
@@ -187,7 +193,7 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date") updatedGallery.Date = translator.optionalDate(input.Date, "date")
updatedGallery.Rating = translator.optionalInt(input.Rating, "rating") updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
@@ -262,8 +268,7 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall
updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date") updatedGallery.Date = translator.optionalDate(input.Date, "date")
updatedGallery.Rating = translator.optionalInt(input.Rating, "rating") updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
var err error var err error
updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {

View File

@@ -103,7 +103,7 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
updatedImage := models.NewImagePartial() updatedImage := models.NewImagePartial()
updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalInt(input.Rating, "rating") updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
@@ -189,7 +189,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
} }
updatedImage.Title = translator.optionalString(input.Title, "title") updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalInt(input.Rating, "rating") updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)

View File

@@ -76,9 +76,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
newMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true} newMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true}
} }
if input.Rating != nil { if input.Rating100 != nil {
rating := int64(*input.Rating) newMovie.Rating = sql.NullInt64{Int64: int64(*input.Rating100), Valid: true}
newMovie.Rating = sql.NullInt64{Int64: rating, Valid: true} } else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newMovie.Rating = sql.NullInt64{Int64: int64(rating), Valid: true}
} }
if input.StudioID != nil { if input.StudioID != nil {
@@ -166,7 +168,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
updatedMovie.Aliases = translator.nullString(input.Aliases, "aliases") updatedMovie.Aliases = translator.nullString(input.Aliases, "aliases")
updatedMovie.Duration = translator.nullInt64(input.Duration, "duration") updatedMovie.Duration = translator.nullInt64(input.Duration, "duration")
updatedMovie.Date = translator.sqliteDate(input.Date, "date") updatedMovie.Date = translator.sqliteDate(input.Date, "date")
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating") updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director") updatedMovie.Director = translator.nullString(input.Director, "director")
updatedMovie.Synopsis = translator.nullString(input.Synopsis, "synopsis") updatedMovie.Synopsis = translator.nullString(input.Synopsis, "synopsis")
@@ -239,7 +241,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
} }
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating") updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director") updatedMovie.Director = translator.nullString(input.Director, "director")

View File

@@ -114,8 +114,11 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
if input.Favorite != nil { if input.Favorite != nil {
newPerformer.Favorite = *input.Favorite newPerformer.Favorite = *input.Favorite
} }
if input.Rating != nil { if input.Rating100 != nil {
newPerformer.Rating = input.Rating newPerformer.Rating = input.Rating100
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newPerformer.Rating = &rating
} }
if input.Details != nil { if input.Details != nil {
newPerformer.Details = *input.Details newPerformer.Details = *input.Details
@@ -239,7 +242,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating, "rating") updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedPerformer.Details = translator.optionalString(input.Details, "details") updatedPerformer.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color")
@@ -352,7 +355,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating, "rating") updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedPerformer.Details = translator.optionalString(input.Details, "details") updatedPerformer.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color")

View File

@@ -172,7 +172,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating") updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter") updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
var err error var err error
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
@@ -348,7 +348,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating") updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)

View File

@@ -58,11 +58,18 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateI
newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true}
} }
if input.Rating != nil { if input.Rating100 != nil {
newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} newStudio.Rating = sql.NullInt64{
} else { Int64: int64(*input.Rating100),
newStudio.Rating = sql.NullInt64{Valid: false} Valid: true,
} }
} else if input.Rating != nil {
newStudio.Rating = sql.NullInt64{
Int64: int64(models.Rating5To100(*input.Rating)),
Valid: true,
}
}
if input.Details != nil { if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true} newStudio.Details = sql.NullString{String: *input.Details, Valid: true}
} }
@@ -150,7 +157,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateI
updatedStudio.URL = translator.nullString(input.URL, "url") updatedStudio.URL = translator.nullString(input.URL, "url")
updatedStudio.Details = translator.nullString(input.Details, "details") updatedStudio.Details = translator.nullString(input.Details, "details")
updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id")
updatedStudio.Rating = translator.nullInt64(input.Rating, "rating") updatedStudio.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedStudio.IgnoreAutoTag = input.IgnoreAutoTag updatedStudio.IgnoreAutoTag = input.IgnoreAutoTag
// Start the transaction and save the studio // Start the transaction and save the studio

View File

@@ -32,15 +32,19 @@ func toSnakeCase(v string) string {
func fromSnakeCase(v string) string { func fromSnakeCase(v string) string {
var buf bytes.Buffer var buf bytes.Buffer
leadingUnderscore := true
capvar := false capvar := false
for i, c := range v { for i, c := range v {
switch { switch {
case c == '_' && i > 0: case c == '_' && !leadingUnderscore && i > 0:
capvar = true capvar = true
case c == '_' && leadingUnderscore:
buf.WriteRune(c)
case capvar: case capvar:
buf.WriteRune(unicode.ToUpper(c)) buf.WriteRune(unicode.ToUpper(c))
capvar = false capvar = false
default: default:
leadingUnderscore = false
buf.WriteRune(c) buf.WriteRune(c)
} }
} }
@@ -54,8 +58,14 @@ func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
for key, val := range m { for key, val := range m {
adjKey := toSnakeCase(key) adjKey := toSnakeCase(key)
switch v := val.(type) {
case map[string]interface{}:
nm[adjKey] = toSnakeCaseMap(v)
default:
nm[adjKey] = val nm[adjKey] = val
} }
}
return nm return nm
} }
@@ -68,13 +78,15 @@ func convertMapValue(val interface{}) interface{} {
case map[interface{}]interface{}: case map[interface{}]interface{}:
ret := cast.ToStringMap(v) ret := cast.ToStringMap(v)
for k, vv := range ret { for k, vv := range ret {
ret[k] = convertMapValue(vv) adjKey := fromSnakeCase(k)
ret[adjKey] = convertMapValue(vv)
} }
return ret return ret
case map[string]interface{}: case map[string]interface{}:
ret := make(map[string]interface{}) ret := make(map[string]interface{})
for k, vv := range v { for k, vv := range v {
ret[k] = convertMapValue(vv) adjKey := fromSnakeCase(k)
ret[adjKey] = convertMapValue(vv)
} }
return ret return ret
case []interface{}: case []interface{}:

View File

@@ -32,6 +32,7 @@ type SceneParserResult struct {
URL *string `json:"url"` URL *string `json:"url"`
Date *string `json:"date"` Date *string `json:"date"`
Rating *int `json:"rating"` Rating *int `json:"rating"`
Rating100 *int `json:"rating100"`
StudioID *string `json:"studio_id"` StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"` GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"` PerformerIds []string `json:"performer_ids"`
@@ -113,6 +114,7 @@ func initParserFields() {
ret["d"] = newParserField("d", `(?:\.|-|_)`, false) ret["d"] = newParserField("d", `(?:\.|-|_)`, false)
ret["rating"] = newParserField("rating", `\d`, true) ret["rating"] = newParserField("rating", `\d`, true)
ret["rating100"] = newParserField("rating100", `\d`, true)
ret["performer"] = newParserField("performer", ".*", true) ret["performer"] = newParserField("performer", ".*", true)
ret["studio"] = newParserField("studio", ".*", true) ret["studio"] = newParserField("studio", ".*", true)
ret["movie"] = newParserField("movie", ".*", true) ret["movie"] = newParserField("movie", ".*", true)
@@ -256,6 +258,10 @@ func validateRating(rating int) bool {
return rating >= 1 && rating <= 5 return rating >= 1 && rating <= 5
} }
func validateRating100(rating100 int) bool {
return rating100 >= 1 && rating100 <= 100
}
func validateDate(dateStr string) bool { func validateDate(dateStr string) bool {
splits := strings.Split(dateStr, "-") splits := strings.Split(dateStr, "-")
if len(splits) != 3 { if len(splits) != 3 {
@@ -347,6 +353,13 @@ func (h *sceneHolder) setField(field parserField, value interface{}) {
case "rating": case "rating":
rating, _ := strconv.Atoi(value.(string)) rating, _ := strconv.Atoi(value.(string))
if validateRating(rating) { if validateRating(rating) {
// convert to 1-100 scale
rating = models.Rating5To100(rating)
h.result.Rating = &rating
}
case "rating100":
rating, _ := strconv.Atoi(value.(string))
if validateRating100(rating) {
h.result.Rating = &rating h.result.Rating = &rating
} }
case "performer": case "performer":

View File

@@ -23,8 +23,10 @@ type GalleryFilterType struct {
IsMissing *string `json:"is_missing"` IsMissing *string `json:"is_missing"`
// Filter to include/exclude galleries that were created from zip // Filter to include/exclude galleries that were created from zip
IsZip *bool `json:"is_zip"` IsZip *bool `json:"is_zip"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized // Filter by organized
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
// Filter by average image resolution // Filter by average image resolution
@@ -65,6 +67,7 @@ type GalleryUpdateInput struct {
Date *string `json:"date"` Date *string `json:"date"`
Details *string `json:"details"` Details *string `json:"details"`
Rating *int `json:"rating"` Rating *int `json:"rating"`
Rating100 *int `json:"rating100"`
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
SceneIds []string `json:"scene_ids"` SceneIds []string `json:"scene_ids"`
StudioID *string `json:"studio_id"` StudioID *string `json:"studio_id"`

View File

@@ -14,8 +14,10 @@ type ImageFilterType struct {
Path *StringCriterionInput `json:"path"` Path *StringCriterionInput `json:"path"`
// Filter by file count // Filter by file count
FileCount *IntCriterionInput `json:"file_count"` FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized // Filter by organized
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
// Filter by o-counter // Filter by o-counter

View File

@@ -16,6 +16,7 @@ type Gallery struct {
URL string `json:"url"` URL string `json:"url"`
Date *Date `json:"date"` Date *Date `json:"date"`
Details string `json:"details"` Details string `json:"details"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
Organized bool `json:"organized"` Organized bool `json:"organized"`
StudioID *int `json:"studio_id"` StudioID *int `json:"studio_id"`
@@ -108,6 +109,7 @@ type GalleryPartial struct {
URL OptionalString URL OptionalString
Date OptionalDate Date OptionalDate
Details OptionalString Details OptionalString
// Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Organized OptionalBool Organized OptionalBool
StudioID OptionalInt StudioID OptionalInt

View File

@@ -15,6 +15,7 @@ type Image struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
Organized bool `json:"organized"` Organized bool `json:"organized"`
OCounter int `json:"o_counter"` OCounter int `json:"o_counter"`
@@ -114,6 +115,7 @@ type ImageCreateInput struct {
type ImagePartial struct { type ImagePartial struct {
Title OptionalString Title OptionalString
// Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Organized OptionalBool Organized OptionalBool
OCounter OptionalInt OCounter OptionalInt

View File

@@ -14,6 +14,7 @@ type Movie struct {
Aliases sql.NullString `db:"aliases" json:"aliases"` Aliases sql.NullString `db:"aliases" json:"aliases"`
Duration sql.NullInt64 `db:"duration" json:"duration"` Duration sql.NullInt64 `db:"duration" json:"duration"`
Date SQLiteDate `db:"date" json:"date"` Date SQLiteDate `db:"date" json:"date"`
// Rating expressed in 1-100 scale
Rating sql.NullInt64 `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director sql.NullString `db:"director" json:"director"` Director sql.NullString `db:"director" json:"director"`
@@ -30,6 +31,7 @@ type MoviePartial struct {
Aliases *sql.NullString `db:"aliases" json:"aliases"` Aliases *sql.NullString `db:"aliases" json:"aliases"`
Duration *sql.NullInt64 `db:"duration" json:"duration"` Duration *sql.NullInt64 `db:"duration" json:"duration"`
Date *SQLiteDate `db:"date" json:"date"` Date *SQLiteDate `db:"date" json:"date"`
// Rating expressed in 1-100 scale
Rating *sql.NullInt64 `db:"rating" json:"rating"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director *sql.NullString `db:"director" json:"director"` Director *sql.NullString `db:"director" json:"director"`

View File

@@ -28,6 +28,7 @@ type Performer struct {
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
Details string `json:"details"` Details string `json:"details"`
DeathDate *Date `json:"death_date"` DeathDate *Date `json:"death_date"`
@@ -60,6 +61,7 @@ type PerformerPartial struct {
Favorite OptionalBool Favorite OptionalBool
CreatedAt OptionalTime CreatedAt OptionalTime
UpdatedAt OptionalTime UpdatedAt OptionalTime
// Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Details OptionalString Details OptionalString
DeathDate OptionalDate DeathDate OptionalDate

View File

@@ -19,6 +19,7 @@ type Scene struct {
Director string `json:"director"` Director string `json:"director"`
URL string `json:"url"` URL string `json:"url"`
Date *Date `json:"date"` Date *Date `json:"date"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
Organized bool `json:"organized"` Organized bool `json:"organized"`
OCounter int `json:"o_counter"` OCounter int `json:"o_counter"`
@@ -140,6 +141,7 @@ type ScenePartial struct {
Director OptionalString Director OptionalString
URL OptionalString URL OptionalString
Date OptionalDate Date OptionalDate
// Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Organized OptionalBool Organized OptionalBool
OCounter OptionalInt OCounter OptionalInt
@@ -176,7 +178,10 @@ type SceneUpdateInput struct {
Director *string `json:"director"` Director *string `json:"director"`
URL *string `json:"url"` URL *string `json:"url"`
Date *string `json:"date"` Date *string `json:"date"`
// Rating expressed in 1-5 scale
Rating *int `json:"rating"` Rating *int `json:"rating"`
// Rating expressed in 1-100 scale
Rating100 *int `json:"rating100"`
OCounter *int `json:"o_counter"` OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"` StudioID *string `json:"studio_id"`
@@ -204,7 +209,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
stashIDs = s.StashIDs.StashIDs stashIDs = s.StashIDs.StashIDs
} }
return SceneUpdateInput{ ret := SceneUpdateInput{
ID: strconv.Itoa(id), ID: strconv.Itoa(id),
Title: s.Title.Ptr(), Title: s.Title.Ptr(),
Code: s.Code.Ptr(), Code: s.Code.Ptr(),
@@ -212,7 +217,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
Director: s.Director.Ptr(), Director: s.Director.Ptr(),
URL: s.URL.Ptr(), URL: s.URL.Ptr(),
Date: dateStr, Date: dateStr,
Rating: s.Rating.Ptr(), Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(), Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(), StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(), GalleryIds: s.GalleryIDs.IDStrings(),
@@ -221,6 +226,14 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
TagIds: s.TagIDs.IDStrings(), TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs, StashIds: stashIDs,
} }
if s.Rating.Set && !s.Rating.Null {
// convert to 1-100 scale
rating := Rating100To5(s.Rating.Value)
ret.Rating = &rating
}
return ret
} }
// GetTitle returns the title of the scene. If the Title field is empty, // GetTitle returns the title of the scene. If the Title field is empty,

View File

@@ -18,7 +18,8 @@ func TestScenePartial_UpdateInput(t *testing.T) {
director = "director" director = "director"
url = "url" url = "url"
date = "2001-02-03" date = "2001-02-03"
rating = 4 ratingLegacy = 4
rating100 = 80
organized = true organized = true
studioID = 2 studioID = 2
studioIDStr = "2" studioIDStr = "2"
@@ -42,7 +43,7 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: NewOptionalString(director), Director: NewOptionalString(director),
URL: NewOptionalString(url), URL: NewOptionalString(url),
Date: NewOptionalDate(dateObj), Date: NewOptionalDate(dateObj),
Rating: NewOptionalInt(rating), Rating: NewOptionalInt(rating100),
Organized: NewOptionalBool(organized), Organized: NewOptionalBool(organized),
StudioID: NewOptionalInt(studioID), StudioID: NewOptionalInt(studioID),
}, },
@@ -54,7 +55,8 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: &director, Director: &director,
URL: &url, URL: &url,
Date: &date, Date: &date,
Rating: &rating, Rating: &ratingLegacy,
Rating100: &rating100,
Organized: &organized, Organized: &organized,
StudioID: &studioIDStr, StudioID: &studioIDStr,
}, },

View File

@@ -15,6 +15,7 @@ type Studio struct {
ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
// Rating expressed in 1-100 scale
Rating sql.NullInt64 `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
Details sql.NullString `db:"details" json:"details"` Details sql.NullString `db:"details" json:"details"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
@@ -28,6 +29,7 @@ type StudioPartial struct {
ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
// Rating expressed in 1-100 scale
Rating *sql.NullInt64 `db:"rating" json:"rating"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
Details *sql.NullString `db:"details" json:"details"` Details *sql.NullString `db:"details" json:"details"`
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`

View File

@@ -8,8 +8,10 @@ type MovieFilterType struct {
Synopsis *StringCriterionInput `json:"synopsis"` Synopsis *StringCriterionInput `json:"synopsis"`
// Filter by duration (in seconds) // Filter by duration (in seconds)
Duration *IntCriterionInput `json:"duration"` Duration *IntCriterionInput `json:"duration"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter to only include movies with this studio // Filter to only include movies with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"` Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include movies missing this property // Filter to only include movies missing this property

View File

@@ -111,8 +111,10 @@ type PerformerFilterType struct {
GalleryCount *IntCriterionInput `json:"gallery_count"` GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by StashID // Filter by StashID
StashID *StringCriterionInput `json:"stash_id"` StashID *StringCriterionInput `json:"stash_id"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url // Filter by url
URL *StringCriterionInput `json:"url"` URL *StringCriterionInput `json:"url"`
// Filter by hair color // Filter by hair color

69
pkg/models/rating.go Normal file
View File

@@ -0,0 +1,69 @@
package models
import (
"fmt"
"io"
"math"
"strconv"
)
type RatingSystem string
const (
FiveStar = "FiveStar"
FivePointFiveStar = "FivePointFiveStar"
FivePointTwoFiveStar = "FivePointTwoFiveStar"
// TenStar = "TenStar"
// TenPointFiveStar = "TenPointFiveStar"
// TenPointTwoFiveStar = "TenPointTwoFiveStar"
TenPointDecimal = "TenPointDecimal"
)
func (e RatingSystem) IsValid() bool {
switch e {
// case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenStar, TenPointFiveStar, TenPointTwoFiveStar, TenPointDecimal:
case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenPointDecimal:
return true
}
return false
}
func (e RatingSystem) String() string {
return string(e)
}
func (e *RatingSystem) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = RatingSystem(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid RatingSystem", str)
}
return nil
}
func (e RatingSystem) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
const (
maxRating100 = 100
maxRating5 = 5
minRating5 = 1
minRating100 = 20
)
// Rating100To5 converts a 1-100 rating to a 1-5 rating.
// Values <= 30 are converted to 1. Otherwise, rating is divided by 20 and rounded to the nearest integer.
func Rating100To5(rating100 int) int {
val := math.Round((float64(rating100) / 20))
return int(math.Max(minRating5, math.Min(maxRating5, val)))
}
// Rating5To100 converts a 1-5 rating to a 1-100 rating
func Rating5To100(rating5 int) int {
return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20))))
}

55
pkg/models/rating_test.go Normal file
View File

@@ -0,0 +1,55 @@
package models
import (
"testing"
)
func TestRating100To5(t *testing.T) {
tests := []struct {
name string
rating100 int
want int
}{
{"20", 20, 1},
{"100", 100, 5},
{"1", 1, 1},
{"10", 10, 1},
{"11", 11, 1},
{"21", 21, 1},
{"31", 31, 2},
{"0", 0, 1},
{"-100", -100, 1},
{"120", 120, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Rating100To5(tt.rating100); got != tt.want {
t.Errorf("Rating100To5() = %v, want %v", got, tt.want)
}
})
}
}
func TestRating5To100(t *testing.T) {
tests := []struct {
name string
rating5 int
want int
}{
{"1", 1, 20},
{"5", 5, 100},
{"2", 2, 40},
{"3", 3, 60},
{"4", 4, 80},
{"6", 6, 100},
{"0", 0, 20},
{"-1", -1, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Rating5To100(tt.rating5); got != tt.want {
t.Errorf("Rating5To100() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -31,8 +31,10 @@ type SceneFilterType struct {
Path *StringCriterionInput `json:"path"` Path *StringCriterionInput `json:"path"`
// Filter by file count // Filter by file count
FileCount *IntCriterionInput `json:"file_count"` FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized // Filter by organized
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
// Filter by o-counter // Filter by o-counter

View File

@@ -14,8 +14,10 @@ type StudioFilterType struct {
StashID *StringCriterionInput `json:"stash_id"` StashID *StringCriterionInput `json:"stash_id"`
// Filter to only include studios missing this property // Filter to only include studios missing this property
IsMissing *string `json:"is_missing"` IsMissing *string `json:"is_missing"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by scene count // Filter by scene count
SceneCount *IntCriterionInput `json:"scene_count"` SceneCount *IntCriterionInput `json:"scene_count"`
// Filter by image count // Filter by image count

View File

@@ -22,7 +22,7 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
var appSchemaVersion uint = 39 var appSchemaVersion uint = 40
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View File

@@ -543,6 +543,25 @@ func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilde
} }
} }
func rating5CriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
// make a copy so we can adjust it
cc := *c
if cc.Value != 0 {
cc.Value = models.Rating5To100(cc.Value)
}
if cc.Value2 != nil {
val := models.Rating5To100(*cc.Value2)
cc.Value2 = &val
}
clause, args := getIntCriterionWhereClause(column, cc)
f.addWhere(clause, args...)
}
}
}
func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc { func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {

View File

@@ -35,6 +35,7 @@ type galleryRow struct {
URL zero.String `db:"url"` URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"` Date models.SQLiteDate `db:"date"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
StudioID null.Int `db:"studio_id,omitempty"` StudioID null.Int `db:"studio_id,omitempty"`
@@ -651,7 +652,9 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga
query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path)) query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path))
query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount))
query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating, "galleries.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating100, "galleries.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(galleryFilter.Rating, "galleries.rating", nil))
query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.URL, "galleries.url")) query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.URL, "galleries.url"))
query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil)) query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil))
query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing))

View File

@@ -54,7 +54,7 @@ func Test_galleryQueryBuilder_Create(t *testing.T) {
var ( var (
title = "title" title = "title"
url = "url" url = "url"
rating = 3 rating = 60
details = "details" details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -205,7 +205,7 @@ func Test_galleryQueryBuilder_Update(t *testing.T) {
var ( var (
title = "title" title = "title"
url = "url" url = "url"
rating = 3 rating = 60
details = "details" details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -399,7 +399,7 @@ func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) {
title = "title" title = "title"
details = "details" details = "details"
url = "url" url = "url"
rating = 3 rating = 60
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -1547,7 +1547,7 @@ func TestGalleryQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
And: &models.GalleryFilterType{ And: &models.GalleryFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: *galleryRating, Value: *galleryRating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -1588,7 +1588,7 @@ func TestGalleryQueryPathNotRating(t *testing.T) {
galleryFilter := models.GalleryFilterType{ galleryFilter := models.GalleryFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.GalleryFilterType{ Not: &models.GalleryFilterType{
Rating: &ratingCriterion, Rating100: &ratingCriterion,
}, },
} }
@@ -1699,32 +1699,32 @@ func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn
}) })
} }
func TestGalleryQueryRating(t *testing.T) { func TestGalleryQueryLegacyRating(t *testing.T) {
const rating = 3 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{
Value: rating, Value: rating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
} }
func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { func verifyGalleriesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Gallery sqb := db.Gallery
galleryFilter := models.GalleryFilterType{ galleryFilter := models.GalleryFilterType{
@@ -1736,6 +1736,54 @@ func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInpu
t.Errorf("Error querying gallery: %s", err.Error()) t.Errorf("Error querying gallery: %s", err.Error())
} }
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, gallery := range galleries {
verifyIntPtr(t, gallery.Rating, ratingCriterion)
}
return nil
})
}
func TestGalleryQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyGalleriesRating100(t, ratingCriterion)
}
func verifyGalleriesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Gallery
galleryFilter := models.GalleryFilterType{
Rating100: &ratingCriterion,
}
galleries, _, err := sqb.Query(ctx, &galleryFilter, nil)
if err != nil {
t.Errorf("Error querying gallery: %s", err.Error())
}
for _, gallery := range galleries { for _, gallery := range galleries {
verifyIntPtr(t, gallery.Rating, ratingCriterion) verifyIntPtr(t, gallery.Rating, ratingCriterion)
} }

View File

@@ -29,6 +29,7 @@ const (
type imageRow struct { type imageRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
OCounter int `db:"o_counter"` OCounter int `db:"o_counter"`
@@ -632,7 +633,9 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF
query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable))
query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount))
query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating, "images.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating100, "images.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(imageFilter.Rating, "images.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil)) query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil)) query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil))

View File

@@ -54,7 +54,7 @@ func loadImageRelationships(ctx context.Context, expected models.Image, actual *
func Test_imageQueryBuilder_Create(t *testing.T) { func Test_imageQueryBuilder_Create(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -208,7 +208,7 @@ func makeImageFileWithID(i int) *file.ImageFile {
func Test_imageQueryBuilder_Update(t *testing.T) { func Test_imageQueryBuilder_Update(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -382,7 +382,7 @@ func clearImagePartial() models.ImagePartial {
func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -1595,7 +1595,7 @@ func TestImageQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
And: &models.ImageFilterType{ And: &models.ImageFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: int(imageRating.Int64), Value: int(imageRating.Int64),
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -1607,7 +1607,10 @@ func TestImageQueryPathAndRating(t *testing.T) {
images := queryImages(ctx, t, sqb, &imageFilter, nil) images := queryImages(ctx, t, sqb, &imageFilter, nil)
assert.Len(t, images, 1) if !assert.Len(t, images, 1) {
return nil
}
assert.Equal(t, imagePath, images[0].Path) assert.Equal(t, imagePath, images[0].Path)
assert.Equal(t, int(imageRating.Int64), *images[0].Rating) assert.Equal(t, int(imageRating.Int64), *images[0].Rating)
@@ -1633,7 +1636,7 @@ func TestImageQueryPathNotRating(t *testing.T) {
imageFilter := models.ImageFilterType{ imageFilter := models.ImageFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.ImageFilterType{ Not: &models.ImageFilterType{
Rating: &ratingCriterion, Rating100: &ratingCriterion,
}, },
} }
@@ -1688,32 +1691,32 @@ func TestImageIllegalQuery(t *testing.T) {
}) })
} }
func TestImageQueryRating(t *testing.T) { func TestImageQueryLegacyRating(t *testing.T) {
const rating = 3 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{
Value: rating, Value: rating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
} }
func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { func verifyImagesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Image sqb := db.Image
imageFilter := models.ImageFilterType{ imageFilter := models.ImageFilterType{
@@ -1725,6 +1728,54 @@ func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
t.Errorf("Error querying image: %s", err.Error()) t.Errorf("Error querying image: %s", err.Error())
} }
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, image := range images {
verifyIntPtr(t, image.Rating, ratingCriterion)
}
return nil
})
}
func TestImageQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyImagesRating100(t, ratingCriterion)
}
func verifyImagesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Image
imageFilter := models.ImageFilterType{
Rating100: &ratingCriterion,
}
images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)
if err != nil {
t.Errorf("Error querying image: %s", err.Error())
}
for _, image := range images { for _, image := range images {
verifyIntPtr(t, image.Rating, ratingCriterion) verifyIntPtr(t, image.Rating, ratingCriterion)
} }

View File

@@ -0,0 +1,6 @@
UPDATE `scenes` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `galleries` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `images` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `movies` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `performers` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `studios` SET `rating` = (`rating` * 20) WHERE `rating` < 6;

View File

@@ -147,7 +147,9 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"))
query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating, "movies.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(movieFilter.Rating, "movies.rating", nil))
query.handleCriterion(ctx, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil)) query.handleCriterion(ctx, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil))
query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url"))

View File

@@ -44,6 +44,7 @@ type performerRow struct {
Favorite sql.NullBool `db:"favorite"` Favorite sql.NullBool `db:"favorite"`
CreatedAt models.SQLiteTimestamp `db:"created_at"` CreatedAt models.SQLiteTimestamp `db:"created_at"`
UpdatedAt models.SQLiteTimestamp `db:"updated_at"` UpdatedAt models.SQLiteTimestamp `db:"updated_at"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
DeathDate models.SQLiteDate `db:"death_date"` DeathDate models.SQLiteDate `db:"death_date"`
@@ -111,6 +112,7 @@ func (r *performerRow) resolve() *models.Performer {
Favorite: r.Favorite.Bool, Favorite: r.Favorite.Bool,
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp,
// expressed as 1-100
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
Details: r.Details.String, Details: r.Details.String,
DeathDate: r.DeathDate.DatePtr(), DeathDate: r.DeathDate.DatePtr(),
@@ -519,7 +521,9 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform
query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings"))
query.handleCriterion(ctx, intCriterionHandler(filter.Rating, tableName+".rating", nil)) query.handleCriterion(ctx, intCriterionHandler(filter.Rating100, tableName+".rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(filter.Rating, tableName+".rating", nil))
query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color")) query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color"))
query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url"))
query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil))

View File

@@ -440,7 +440,7 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
And: &models.PerformerFilterType{ And: &models.PerformerFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: performerRating, Value: performerRating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -450,7 +450,10 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
performers := queryPerformers(ctx, t, &performerFilter, nil) performers := queryPerformers(ctx, t, &performerFilter, nil)
assert.Len(t, performers, 1) if !assert.Len(t, performers, 1) {
return nil
}
assert.Equal(t, performerEth, performers[0].Ethnicity) assert.Equal(t, performerEth, performers[0].Ethnicity)
if assert.NotNil(t, performers[0].Rating) { if assert.NotNil(t, performers[0].Rating) {
assert.Equal(t, performerRating, *performers[0].Rating) assert.Equal(t, performerRating, *performers[0].Rating)
@@ -478,7 +481,7 @@ func TestPerformerQueryEthnicityNotRating(t *testing.T) {
performerFilter := models.PerformerFilterType{ performerFilter := models.PerformerFilterType{
Ethnicity: &ethCriterion, Ethnicity: &ethCriterion,
Not: &models.PerformerFilterType{ Not: &models.PerformerFilterType{
Rating: &ratingCriterion, Rating100: &ratingCriterion,
}, },
} }
@@ -1173,32 +1176,32 @@ func TestPerformerStashIDs(t *testing.T) {
t.Error(err.Error()) t.Error(err.Error())
} }
} }
func TestPerformerQueryRating(t *testing.T) { func TestPerformerQueryLegacyRating(t *testing.T) {
const rating = 3 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{
Value: rating, Value: rating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyPerformersRating(t, ratingCriterion) verifyPerformersLegacyRating(t, ratingCriterion)
} }
func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInput) { func verifyPerformersLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
performerFilter := models.PerformerFilterType{ performerFilter := models.PerformerFilterType{
Rating: &ratingCriterion, Rating: &ratingCriterion,
@@ -1206,6 +1209,50 @@ func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInp
performers := queryPerformers(ctx, t, &performerFilter, nil) performers := queryPerformers(ctx, t, &performerFilter, nil)
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, performer := range performers {
verifyIntPtr(t, performer.Rating, ratingCriterion)
}
return nil
})
}
func TestPerformerQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyPerformersRating100(t, ratingCriterion)
}
func verifyPerformersRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
performerFilter := models.PerformerFilterType{
Rating100: &ratingCriterion,
}
performers := queryPerformers(ctx, t, &performerFilter, nil)
for _, performer := range performers { for _, performer := range performers {
verifyIntPtr(t, performer.Rating, ratingCriterion) verifyIntPtr(t, performer.Rating, ratingCriterion)
} }

View File

@@ -59,6 +59,7 @@ type sceneRow struct {
Director zero.String `db:"director"` Director zero.String `db:"director"`
URL zero.String `db:"url"` URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"` Date models.SQLiteDate `db:"date"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
OCounter int `db:"o_counter"` OCounter int `db:"o_counter"`
@@ -844,7 +845,9 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF
} }
})) }))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil)) query.handleCriterion(ctx, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil)) query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil))

View File

@@ -77,7 +77,7 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -304,7 +304,7 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -512,7 +512,7 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -2295,7 +2295,7 @@ func TestSceneQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
And: &models.SceneFilterType{ And: &models.SceneFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: sceneRating, Value: sceneRating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -2335,7 +2335,7 @@ func TestSceneQueryPathNotRating(t *testing.T) {
sceneFilter := models.SceneFilterType{ sceneFilter := models.SceneFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.SceneFilterType{ Not: &models.SceneFilterType{
Rating: &ratingCriterion, Rating100: &ratingCriterion,
}, },
} }
@@ -2522,25 +2522,25 @@ func TestSceneQueryRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
} }
func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { func verifyScenesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Scene sqb := db.Scene
sceneFilter := models.SceneFilterType{ sceneFilter := models.SceneFilterType{
@@ -2549,6 +2549,51 @@ func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) scenes := queryScene(ctx, t, sqb, &sceneFilter, nil)
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, scene := range scenes {
verifyIntPtr(t, scene.Rating, ratingCriterion)
}
return nil
})
}
func TestSceneQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyScenesRating100(t, ratingCriterion)
}
func verifyScenesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneFilter := models.SceneFilterType{
Rating100: &ratingCriterion,
}
scenes := queryScene(ctx, t, sqb, &sceneFilter, nil)
for _, scene := range scenes { for _, scene := range scenes {
verifyIntPtr(t, scene.Rating, ratingCriterion) verifyIntPtr(t, scene.Rating, ratingCriterion)
} }

View File

@@ -823,7 +823,7 @@ func getSceneTitle(index int) string {
func getRating(index int) sql.NullInt64 { func getRating(index int) sql.NullInt64 {
rating := index % 6 rating := index % 6
return sql.NullInt64{Int64: int64(rating), Valid: rating > 0} return sql.NullInt64{Int64: int64(rating * 20), Valid: rating > 0}
} }
func getIntPtr(r sql.NullInt64) *int { func getIntPtr(r sql.NullInt64) *int {
@@ -967,11 +967,13 @@ func makeScene(i int) *models.Scene {
} }
} }
rating := getRating(i)
return &models.Scene{ return &models.Scene{
Title: title, Title: title,
Details: details, Details: details,
URL: getSceneEmptyString(i, urlField), URL: getSceneEmptyString(i, urlField),
Rating: getIntPtr(getRating(i)), Rating: getIntPtr(rating),
OCounter: getOCounter(i), OCounter: getOCounter(i),
Date: getObjectDateObject(i), Date: getObjectDateObject(i),
StudioID: studioID, StudioID: studioID,

View File

@@ -234,7 +234,9 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Name, studioTable+".name")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Name, studioTable+".name"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url"))
query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating, studioTable+".rating", nil)) query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(studioFilter.Rating, studioTable+".rating", nil))
query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil)) query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil))
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {

View File

@@ -61,7 +61,7 @@
"no-descending-specificity": null, "no-descending-specificity": null,
"no-invalid-double-slash-comments": true, "no-invalid-double-slash-comments": true,
"no-missing-end-of-source-newline": true, "no-missing-end-of-source-newline": true,
"number-max-precision": 2, "number-max-precision": 3,
"number-no-trailing-zeros": true, "number-no-trailing-zeros": true,
"order/order": [ "order/order": [
"custom-properties", "custom-properties",

View File

@@ -8,7 +8,7 @@
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"build-ci": "yarn validate && yarn build", "build-ci": "yarn validate && yarn build",
"validate": "yarn lint && yarn format-check && tsc --noEmit", "validate": "yarn lint && tsc --noEmit && yarn format-check",
"lint": "yarn lint:css && yarn lint:js", "lint": "yarn lint:css && yarn lint:js",
"lint:js": "eslint --cache src/**/*.{ts,tsx}", "lint:js": "eslint --cache src/**/*.{ts,tsx}",
"lint:css": "stylelint \"src/**/*.scss\"", "lint:css": "stylelint \"src/**/*.scss\"",

0
ui/v2.5/src/App.tsx Executable file → Normal file
View File

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react"; import React, { useContext, useMemo } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { import {
FrontPageContent, FrontPageContent,
@@ -7,6 +7,7 @@ import {
} from "src/core/config"; } from "src/core/config";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useFindSavedFilter } from "src/core/StashService"; import { useFindSavedFilter } from "src/core/StashService";
import { ConfigurationContext } from "src/hooks/Config";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow"; import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow";
import { ImageRecommendationRow } from "../Images/ImageRecommendationRow"; import { ImageRecommendationRow } from "../Images/ImageRecommendationRow";
@@ -98,6 +99,7 @@ interface ISavedFilterResults {
const SavedFilterResults: React.FC<ISavedFilterResults> = ({ const SavedFilterResults: React.FC<ISavedFilterResults> = ({
savedFilterID, savedFilterID,
}) => { }) => {
const { configuration: config } = useContext(ConfigurationContext);
const { loading, data } = useFindSavedFilter(savedFilterID.toString()); const { loading, data } = useFindSavedFilter(savedFilterID.toString());
const filter = useMemo(() => { const filter = useMemo(() => {
@@ -105,12 +107,12 @@ const SavedFilterResults: React.FC<ISavedFilterResults> = ({
const { mode, filter: filterJSON } = data.findSavedFilter; const { mode, filter: filterJSON } = data.findSavedFilter;
const ret = new ListFilterModel(mode); const ret = new ListFilterModel(mode, config);
ret.currentPage = 1; ret.currentPage = 1;
ret.configureFromJSON(filterJSON); ret.configureFromJSON(filterJSON);
ret.randomSeed = -1; ret.randomSeed = -1;
return ret; return ret;
}, [data?.findSavedFilter]); }, [data?.findSavedFilter, config]);
if (loading || !data?.findSavedFilter || !filter) { if (loading || !data?.findSavedFilter || !filter) {
return <></>; return <></>;
@@ -128,18 +130,19 @@ interface ICustomFilterProps {
const CustomFilterResults: React.FC<ICustomFilterProps> = ({ const CustomFilterResults: React.FC<ICustomFilterProps> = ({
customFilter, customFilter,
}) => { }) => {
const { configuration: config } = useContext(ConfigurationContext);
const intl = useIntl(); const intl = useIntl();
const filter = useMemo(() => { const filter = useMemo(() => {
const itemsPerPage = 25; const itemsPerPage = 25;
const ret = new ListFilterModel(customFilter.mode); const ret = new ListFilterModel(customFilter.mode, config);
ret.sortBy = customFilter.sortBy; ret.sortBy = customFilter.sortBy;
ret.sortDirection = customFilter.direction; ret.sortDirection = customFilter.direction;
ret.itemsPerPage = itemsPerPage; ret.itemsPerPage = itemsPerPage;
ret.currentPage = 1; ret.currentPage = 1;
ret.randomSeed = -1; ret.randomSeed = -1;
return ret; return ret;
}, [customFilter]); }, [customFilter, config]);
const header = customFilter.message const header = customFilter.message
? intl.formatMessage( ? intl.formatMessage(

View File

@@ -8,7 +8,7 @@ import { StudioSelect, Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputIDs, getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
@@ -29,7 +29,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [ const [
performerMode, performerMode,
@@ -64,7 +64,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
galleryInput.rating = getAggregateInputValue(rating, aggregateRating); galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
galleryInput.studio_id = getAggregateInputValue( galleryInput.studio_id = getAggregateInputValue(
studioId, studioId,
aggregateStudioId aggregateStudioId
@@ -121,7 +121,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
let first = true; let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => { state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
const galleryRating = gallery.rating; const galleryRating = gallery.rating100;
const GalleriestudioID = gallery?.studio?.id; const GalleriestudioID = gallery?.studio?.id;
const galleryPerformerIDs = (gallery.performers ?? []) const galleryPerformerIDs = (gallery.performers ?? [])
.map((p) => p.id) .map((p) => p.id)
@@ -256,14 +256,13 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

@@ -160,7 +160,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
src={`${props.gallery.cover.paths.thumbnail}`} src={`${props.gallery.cover.paths.thumbnail}`}
/> />
) : undefined} ) : undefined}
<RatingBanner rating={props.gallery.rating} /> <RatingBanner rating={props.gallery.rating100} />
</> </>
} }
overlays={maybeRenderSceneStudioOverlay()} overlays={maybeRenderSceneStudioOverlay()}

View File

@@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TagLink, TruncatedText } from "src/components/Shared"; import { TagLink, TruncatedText } from "src/components/Shared";
import { PerformerCard } from "src/components/Performers/PerformerCard"; import { PerformerCard } from "src/components/Performers/PerformerCard";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
@@ -94,10 +94,10 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
/> />
</h5> </h5>
) : undefined} ) : undefined}
{gallery.rating ? ( {gallery.rating100 ? (
<h6> <h6>
<FormattedMessage id="rating" />:{" "} <FormattedMessage id="rating" />:{" "}
<RatingStars value={gallery.rating} /> <RatingSystem value={gallery.rating100} disabled />
</h6> </h6>
) : ( ) : (
"" ""

View File

@@ -32,7 +32,7 @@ import {
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
@@ -89,7 +89,7 @@ export const GalleryEditPanel: React.FC<
details: yup.string().optional().nullable(), details: yup.string().optional().nullable(),
url: yup.string().optional().nullable(), url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(), date: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(), rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(), studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(), tag_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -101,7 +101,7 @@ export const GalleryEditPanel: React.FC<
details: gallery?.details ?? "", details: gallery?.details ?? "",
url: gallery?.url ?? "", url: gallery?.url ?? "",
date: gallery?.date ?? "", date: gallery?.date ?? "",
rating: gallery?.rating ?? null, rating100: gallery?.rating100 ?? null,
studio_id: gallery?.studio?.id, studio_id: gallery?.studio?.id,
performer_ids: (gallery?.performers ?? []).map((p) => p.id), performer_ids: (gallery?.performers ?? []).map((p) => p.id),
tag_ids: (gallery?.tags ?? []).map((t) => t.id), tag_ids: (gallery?.tags ?? []).map((t) => t.id),
@@ -117,7 +117,7 @@ export const GalleryEditPanel: React.FC<
}); });
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating", v); formik.setFieldValue("rating100", v);
} }
interface ISceneSelectValue { interface ISceneSelectValue {
@@ -150,11 +150,11 @@ export const GalleryEditPanel: React.FC<
} }
Mousetrap.bind("0", () => setRating(NaN)); Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1)); Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(2)); Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(3)); Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(4)); Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(5)); Mousetrap.bind("5", () => setRating(100));
setTimeout(() => { setTimeout(() => {
Mousetrap.unbind("0"); Mousetrap.unbind("0");
@@ -483,15 +483,14 @@ export const GalleryEditPanel: React.FC<
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={formik.values.rating ?? undefined} value={formik.values.rating100 ?? undefined}
onSetRating={(value) => onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null) formik.setFieldValue("rating100", value ?? null)
} }
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

@@ -2,10 +2,11 @@ import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { RatingStars, TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { useGalleryLightbox } from "src/hooks"; import { useGalleryLightbox } from "src/hooks";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
const CLASSNAME = "GalleryWallCard"; const CLASSNAME = "GalleryWallCard";
const CLASSNAME_FOOTER = `${CLASSNAME}-footer`; const CLASSNAME_FOOTER = `${CLASSNAME}-footer`;
@@ -45,7 +46,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
<RatingStars rating={gallery.rating} /> <RatingSystem value={gallery.rating100 ?? undefined} disabled />
<img src={cover} alt="" className={CLASSNAME_IMG} /> <img src={cover} alt="" className={CLASSNAME_IMG} />
<footer className={CLASSNAME_FOOTER}> <footer className={CLASSNAME_FOOTER}>
<Link <Link

View File

@@ -204,16 +204,25 @@ $galleryTabWidth: 450px;
} }
} }
.RatingStars { .rating-stars,
.rating-number {
position: absolute; position: absolute;
right: 1rem; right: 1rem;
top: 1rem; top: 1rem;
}
&-unfilled { .rating-stars {
.star-fill-0 .unfilled-star {
display: none; display: none;
} }
&-filled { .star-fill-25 .unfilled-star,
.star-fill-50 .unfilled-star,
.star-fill-75 .unfilled-star {
visibility: hidden;
}
.filled-star {
filter: drop-shadow(1px 1px 1px #222); filter: drop-shadow(1px 1px 1px #222);
} }
} }

View File

@@ -8,7 +8,7 @@ import { StudioSelect, Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputIDs, getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
@@ -29,7 +29,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [ const [
performerMode, performerMode,
@@ -64,7 +64,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
imageInput.rating = getAggregateInputValue(rating, aggregateRating); imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
imageInput.performer_ids = getAggregateInputIDs( imageInput.performer_ids = getAggregateInputIDs(
@@ -112,7 +112,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
let first = true; let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => { state.forEach((image: GQL.SlimImageDataFragment) => {
const imageRating = image.rating; const imageRating = image.rating100;
const imageStudioID = image?.studio?.id; const imageStudioID = image?.studio?.id;
const imagePerformerIDs = (image.performers ?? []) const imagePerformerIDs = (image.performers ?? [])
.map((p) => p.id) .map((p) => p.id)
@@ -246,14 +246,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

@@ -157,7 +157,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
</div> </div>
) : undefined} ) : undefined}
</div> </div>
<RatingBanner rating={props.image.rating} /> <RatingBanner rating={props.image.rating100} />
</> </>
} }
popovers={maybeRenderPopoverButtonGroup()} popovers={maybeRenderPopoverButtonGroup()}

View File

@@ -4,7 +4,7 @@ import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TagLink, TruncatedText } from "src/components/Shared"; import { TagLink, TruncatedText } from "src/components/Shared";
import { PerformerCard } from "src/components/Performers/PerformerCard"; import { PerformerCard } from "src/components/Performers/PerformerCard";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
@@ -91,10 +91,10 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
<TruncatedText text={objectTitle(props.image)} /> <TruncatedText text={objectTitle(props.image)} />
</h3> </h3>
</div> </div>
{props.image.rating ? ( {props.image.rating100 ? (
<h6> <h6>
<FormattedMessage id="rating" />:{" "} <FormattedMessage id="rating" />:{" "}
<RatingStars value={props.image.rating} /> <RatingSystem value={props.image.rating100} disabled />
</h6> </h6>
) : ( ) : (
"" ""

View File

@@ -15,7 +15,7 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@@ -38,7 +38,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().optional().nullable(), title: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(), rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(), studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(), tag_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -46,7 +46,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const initialValues = { const initialValues = {
title: image.title ?? "", title: image.title ?? "",
rating: image.rating ?? null, rating100: image.rating100 ?? null,
studio_id: image.studio?.id, studio_id: image.studio?.id,
performer_ids: (image.performers ?? []).map((p) => p.id), performer_ids: (image.performers ?? []).map((p) => p.id),
tag_ids: (image.tags ?? []).map((t) => t.id), tag_ids: (image.tags ?? []).map((t) => t.id),
@@ -61,7 +61,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
}); });
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating", v); formik.setFieldValue("rating100", v);
} }
useEffect(() => { useEffect(() => {
@@ -81,11 +81,11 @@ export const ImageEditPanel: React.FC<IProps> = ({
} }
Mousetrap.bind("0", () => setRating(NaN)); Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1)); Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(2)); Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(3)); Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(4)); Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(5)); Mousetrap.bind("5", () => setRating(100));
setTimeout(() => { setTimeout(() => {
Mousetrap.unbind("0"); Mousetrap.unbind("0");
@@ -194,15 +194,14 @@ export const ImageEditPanel: React.FC<IProps> = ({
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={formik.values.rating ?? undefined} value={formik.values.rating100 ?? undefined}
onSetRating={(value) => onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null) formik.setFieldValue("rating100", value ?? null)
} }
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

@@ -1,5 +1,5 @@
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import React, { useEffect, useRef, useState } from "react"; import React, { useContext, useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap"; import { Button, Form, Modal } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
@@ -36,6 +36,9 @@ import { DateFilter } from "./Filters/DateFilter";
import { TimestampFilter } from "./Filters/TimestampFilter"; import { TimestampFilter } from "./Filters/TimestampFilter";
import { CountryCriterion } from "src/models/list-filter/criteria/country"; import { CountryCriterion } from "src/models/list-filter/criteria/country";
import { CountrySelect } from "../Shared"; import { CountrySelect } from "../Shared";
import { ConfigurationContext } from "src/hooks/Config";
import { RatingCriterion } from "../../models/list-filter/criteria/rating";
import { RatingFilter } from "./Filters/RatingFilter";
interface IAddFilterProps { interface IAddFilterProps {
onAddCriterion: ( onAddCriterion: (
@@ -63,17 +66,18 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
const { options, modifierOptions } = criterion.criterionOption; const { options, modifierOptions } = criterion.criterionOption;
const valueStage = useRef<CriterionValue>(criterion.value); const valueStage = useRef<CriterionValue>(criterion.value);
const { configuration: config } = useContext(ConfigurationContext);
const intl = useIntl(); const intl = useIntl();
// Configure if we are editing an existing criterion // Configure if we are editing an existing criterion
useEffect(() => { useEffect(() => {
if (!editingCriterion) { if (!editingCriterion) {
setCriterion(makeCriteria()); setCriterion(makeCriteria(config));
} else { } else {
setCriterion(editingCriterion); setCriterion(editingCriterion);
} }
}, [editingCriterion]); }, [config, editingCriterion]);
useEffect(() => { useEffect(() => {
valueStage.current = criterion.value; valueStage.current = criterion.value;
@@ -81,7 +85,7 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) { function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType; const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType); const newCriterion = makeCriteria(config, newCriterionType);
setCriterion(newCriterion); setCriterion(newCriterion);
} }
@@ -196,6 +200,15 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} /> <NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
); );
} }
if (criterion instanceof RatingCriterion) {
return (
<RatingFilter
criterion={criterion}
onValueChanged={onValueChanged}
configuration={config}
/>
);
}
if ( if (
criterion instanceof CountryCriterion && criterion instanceof CountryCriterion &&
(criterion.modifier === CriterionModifier.Equals || (criterion.modifier === CriterionModifier.Equals ||

View File

@@ -0,0 +1,135 @@
import React, { useRef } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { INumberValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import {
convertFromRatingFormat,
convertToRatingFormat,
defaultRatingSystemOptions,
} from "src/utils/rating";
import * as GQL from "src/core/generated-graphql";
import { IUIConfig } from "src/core/config";
interface IDurationFilterProps {
criterion: Criterion<INumberValue>;
onValueChanged: (value: INumberValue) => void;
configuration: GQL.ConfigDataFragment | undefined;
}
export const RatingFilter: React.FC<IDurationFilterProps> = ({
criterion,
onValueChanged,
configuration,
}) => {
const intl = useIntl();
const ratingSystem =
(configuration?.ui as IUIConfig)?.ratingSystemOptions ??
defaultRatingSystemOptions;
const valueStage = useRef<INumberValue>(criterion.value);
function onChanged(
event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2"
) {
const value = parseInt(event.target.value, 10);
valueStage.current[property] = !Number.isNaN(value)
? convertFromRatingFormat(value, ratingSystem.type)
: 0;
}
function onBlurInput() {
onValueChanged(valueStage.current);
}
let equalsControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.Equals ||
criterion.modifier === CriterionModifier.NotEquals
) {
equalsControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(criterion.value?.value, ratingSystem) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.value" })}
/>
</Form.Group>
);
}
let lowerControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.GreaterThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
lowerControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(criterion.value?.value, ratingSystem) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/>
</Form.Group>
);
}
let upperControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.LessThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
upperControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(
e,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(
criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value
: criterion.value?.value2,
ratingSystem
) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
/>
</Form.Group>
);
}
return (
<>
{equalsControl}
{lowerControl}
{upperControl}
</>
);
};

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { Modal, StudioSelect } from "src/components/Shared"; import { Modal, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputValue, getAggregateInputValue,
getAggregateRating, getAggregateRating,
@@ -24,7 +24,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number | undefined>(); const [rating100, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>(); const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>(); const [director, setDirector] = useState<string | undefined>();
@@ -42,7 +42,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
}; };
// if rating is undefined // if rating is undefined
movieInput.rating = getAggregateInputValue(rating, aggregateRating); movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
return movieInput; return movieInput;
@@ -77,11 +77,11 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
state.forEach((movie: GQL.MovieDataFragment) => { state.forEach((movie: GQL.MovieDataFragment) => {
if (first) { if (first) {
first = false; first = false;
updateRating = movie.rating ?? undefined; updateRating = movie.rating100 ?? undefined;
updateStudioId = movie.studio?.id ?? undefined; updateStudioId = movie.studio?.id ?? undefined;
updateDirector = movie.director ?? undefined; updateDirector = movie.director ?? undefined;
} else { } else {
if (movie.rating !== updateRating) { if (movie.rating100 !== updateRating) {
updateRating = undefined; updateRating = undefined;
} }
if (movie.studio?.id !== updateStudioId) { if (movie.studio?.id !== updateStudioId) {
@@ -124,8 +124,8 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />

View File

@@ -82,7 +82,7 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
alt={props.movie.name ?? ""} alt={props.movie.name ?? ""}
src={props.movie.front_image_path ?? ""} src={props.movie.front_image_path ?? ""}
/> />
<RatingBanner rating={props.movie.rating} /> <RatingBanner rating={props.movie.rating100} />
</> </>
} }
details={ details={

View File

@@ -2,7 +2,7 @@ import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { DurationUtils, TextUtils } from "src/utils"; import { DurationUtils, TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
interface IMovieDetailsPanel { interface IMovieDetailsPanel {
@@ -27,7 +27,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
} }
function renderRatingField() { function renderRatingField() {
if (!movie.rating) { if (!movie.rating100) {
return; return;
} }
@@ -35,7 +35,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
<> <>
<dt>{intl.formatMessage({ id: "rating" })}</dt> <dt>{intl.formatMessage({ id: "rating" })}</dt>
<dd> <dd>
<RatingStars value={movie.rating} disabled /> <RatingSystem value={movie.rating100} disabled />
</dd> </dd>
</> </>
); );

View File

@@ -17,7 +17,7 @@ import {
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap"; import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap";
import { DurationUtils, FormUtils, ImageUtils } from "src/utils"; import { DurationUtils, FormUtils, ImageUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { MovieScrapeDialog } from "./MovieScrapeDialog"; import { MovieScrapeDialog } from "./MovieScrapeDialog";
@@ -69,7 +69,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
.optional() .optional()
.nullable() .nullable()
.matches(/^\d{4}-\d{2}-\d{2}$/), .matches(/^\d{4}-\d{2}-\d{2}$/),
rating: yup.number().optional().nullable(), rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(), studio_id: yup.string().optional().nullable(),
director: yup.string().optional().nullable(), director: yup.string().optional().nullable(),
synopsis: yup.string().optional().nullable(), synopsis: yup.string().optional().nullable(),
@@ -83,7 +83,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
aliases: movie?.aliases, aliases: movie?.aliases,
duration: movie?.duration, duration: movie?.duration,
date: movie?.date, date: movie?.date,
rating: movie?.rating ?? null, rating100: movie?.rating100 ?? null,
studio_id: movie?.studio?.id, studio_id: movie?.studio?.id,
director: movie?.director, director: movie?.director,
synopsis: movie?.synopsis, synopsis: movie?.synopsis,
@@ -116,17 +116,17 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
]); ]);
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating", v); formik.setFieldValue("rating100", v);
} }
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("r 0", () => setRating(NaN)); Mousetrap.bind("r 0", () => setRating(NaN));
Mousetrap.bind("r 1", () => setRating(1)); Mousetrap.bind("r 1", () => setRating(20));
Mousetrap.bind("r 2", () => setRating(2)); Mousetrap.bind("r 2", () => setRating(40));
Mousetrap.bind("r 3", () => setRating(3)); Mousetrap.bind("r 3", () => setRating(60));
Mousetrap.bind("r 4", () => setRating(4)); Mousetrap.bind("r 4", () => setRating(80));
Mousetrap.bind("r 5", () => setRating(5)); Mousetrap.bind("r 5", () => setRating(100));
// Mousetrap.bind("u", (e) => { // Mousetrap.bind("u", (e) => {
// setStudioFocus() // setStudioFocus()
// e.preventDefault(); // e.preventDefault();
@@ -164,7 +164,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
function getMovieInput(values: InputValues) { function getMovieInput(values: InputValues) {
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = { const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...values, ...values,
rating: values.rating ?? null, rating100: values.rating100 ?? null,
studio_id: values.studio_id ?? null, studio_id: values.studio_id ?? null,
}; };
@@ -432,15 +432,14 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={formik.values.rating ?? undefined} value={formik.values.rating100 ?? undefined}
onSetRating={(value) => onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null) formik.setFieldValue("rating100", value ?? null)
} }
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="url" as={Row}> <Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "url" }), title: intl.formatMessage({ id: "url" }),

View File

@@ -1,13 +1,12 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap"; import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useBulkPerformerUpdate } from "src/core/StashService"; import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared"; import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputValue, getAggregateInputValue,
getAggregateState, getAggregateState,
@@ -21,6 +20,7 @@ import {
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { FormUtils } from "../../utils";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[]; selected: GQL.SlimPerformerDataFragment[];
@@ -32,7 +32,7 @@ const performerFields = [
"url", "url",
"instagram", "instagram",
"twitter", "twitter",
"rating", "rating100",
"gender", "gender",
"birthdate", "birthdate",
"death_date", "death_date",
@@ -90,9 +90,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
// we don't have unset functionality for the rating star control // we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not // so need to determine if we are setting a rating or not
performerInput.rating = getAggregateInputValue( performerInput.rating100 = getAggregateInputValue(
updateInput.rating, updateInput.rating100,
aggregateState.rating aggregateState.rating100
); );
// gender dropdown doesn't have unset functionality // gender dropdown doesn't have unset functionality
@@ -205,9 +205,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={updateInput.rating ?? undefined} value={updateInput.rating100 ?? undefined}
onSetRating={(value) => setUpdateField({ rating: value })} onSetRating={(value) => setUpdateField({ rating100: value })}
disabled={isUpdating} disabled={isUpdating}
/> />
</Col> </Col>

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; import { NavUtils, TextUtils } from "src/utils";
import { import {
@@ -18,6 +18,7 @@ import {
import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton";
import GenderIcon from "./GenderIcon"; import GenderIcon from "./GenderIcon";
import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons"; import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons";
import { RatingBanner } from "../Shared/RatingBanner";
export interface IPerformerCardExtraCriteria { export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[]; scenes: Criterion<CriterionValue>[];
@@ -167,18 +168,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
} }
function maybeRenderRatingBanner() { function maybeRenderRatingBanner() {
if (!performer.rating) { if (!performer.rating100) {
return; return;
} }
return ( return <RatingBanner rating={performer.rating100} />;
<div
className={`rating-banner ${
performer.rating ? `rating-${performer.rating}` : ""
}`}
>
<FormattedMessage id="rating" />: {performer.rating}
</div>
);
} }
function maybeRenderFlag() { function maybeRenderFlag() {

View File

@@ -23,7 +23,7 @@ import {
import { useLightbox, useToast } from "src/hooks"; import { useLightbox, useToast } from "src/hooks";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
@@ -127,11 +127,11 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
Mousetrap.bind("0", () => setRating(NaN)); Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1)); Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(2)); Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(3)); Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(4)); Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(5)); Mousetrap.bind("5", () => setRating(100));
setTimeout(() => { setTimeout(() => {
Mousetrap.unbind("0"); Mousetrap.unbind("0");
@@ -327,7 +327,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
variables: { variables: {
input: { input: {
id: performer.id, id: performer.id,
rating: v, rating100: v,
}, },
}, },
}); });
@@ -428,8 +428,8 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
{performer.name} {performer.name}
{renderClickableIcons()} {renderClickableIcons()}
</h2> </h2>
<RatingStars <RatingSystem
value={performer.rating ?? undefined} value={performer.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)} onSetRating={(value) => setRating(value ?? null)}
/> />
{maybeRenderAliases()} {maybeRenderAliases()}

View File

@@ -13,7 +13,7 @@ export class ParserField {
static Title = new ParserField("title"); static Title = new ParserField("title");
static Ext = new ParserField("ext", "File extension"); static Ext = new ParserField("ext", "File extension");
static Rating = new ParserField("rating"); static Rating = new ParserField("rating100");
static I = new ParserField("i", "Matches any ignored word"); static I = new ParserField("i", "Matches any ignored word");
static D = new ParserField("d", "Matches any delimiter (.-_)"); static D = new ParserField("d", "Matches any delimiter (.-_)");

View File

@@ -77,7 +77,7 @@ export const SceneFilenameParser: React.FC = () => {
ParserField.fullDateFields.some((f) => { ParserField.fullDateFields.some((f) => {
return pattern.includes(`{${f.field}}`); return pattern.includes(`{${f.field}}`);
}); });
const ratingSet = pattern.includes("{rating}"); const ratingSet = pattern.includes("{rating100}");
const performerSet = pattern.includes("{performer}"); const performerSet = pattern.includes("{performer}");
const tagSet = pattern.includes("{tag}"); const tagSet = pattern.includes("{tag}");
const studioSet = pattern.includes("{studio}"); const studioSet = pattern.includes("{studio}");

View File

@@ -54,7 +54,7 @@ export class SceneParserResult {
this.filename = objectTitle(this.scene); this.filename = objectTitle(this.scene);
this.title.setOriginalValue(this.scene.title ?? undefined); this.title.setOriginalValue(this.scene.title ?? undefined);
this.date.setOriginalValue(this.scene.date ?? undefined); this.date.setOriginalValue(this.scene.date ?? undefined);
this.rating.setOriginalValue(this.scene.rating ?? undefined); this.rating.setOriginalValue(this.scene.rating100 ?? undefined);
this.performers.setOriginalValue(this.scene.performers.map((p) => p.id)); this.performers.setOriginalValue(this.scene.performers.map((p) => p.id));
this.tags.setOriginalValue(this.scene.tags.map((t) => t.id)); this.tags.setOriginalValue(this.scene.tags.map((t) => t.id));
this.studio.setOriginalValue(this.scene.studio?.id); this.studio.setOriginalValue(this.scene.studio?.id);

View File

@@ -8,7 +8,7 @@ import { StudioSelect, Modal } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "./SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputIDs, getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
@@ -30,7 +30,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [ const [
performerMode, performerMode,
@@ -71,7 +71,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
sceneInput.rating = getAggregateInputValue(rating, aggregateRating); sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
sceneInput.performer_ids = getAggregateInputIDs( sceneInput.performer_ids = getAggregateInputIDs(
@@ -121,7 +121,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
let first = true; let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating; const sceneRating = scene.rating100;
const sceneStudioID = scene?.studio?.id; const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? []) const scenePerformerIDs = (scene.performers ?? [])
.map((p) => p.id) .map((p) => p.id)
@@ -271,14 +271,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

@@ -401,7 +401,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
isPortrait={isPortrait()} isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false} soundActive={configuration?.interface?.soundOnPreview ?? false}
/> />
<RatingBanner rating={props.scene.rating} /> <RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()} {maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()} {maybeRenderInteractiveSpeedOverlay()}
</> </>

View File

@@ -1,119 +0,0 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import Icon from "src/components/Shared/Icon";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
import { faStar as farStar } from "@fortawesome/free-regular-svg-icons";
export interface IRatingStarsProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingStars: React.FC<IRatingStarsProps> = (
props: IRatingStarsProps
) => {
const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating;
function setRating(rating: number) {
if (!props.onSetRating) {
return;
}
let newRating: number | undefined = rating;
// unset if we're clicking on the current rating
if (props.value === rating) {
newRating = undefined;
}
// set the hover rating to undefined so that it doesn't immediately clear
// the stars
setHoverRating(undefined);
props.onSetRating(newRating);
}
function getIcon(rating: number) {
if (hoverRating && hoverRating >= rating) {
if (hoverRating === props.value) {
return farStar;
}
return fasStar;
}
if (!hoverRating && props.value && props.value >= rating) {
return fasStar;
}
return farStar;
}
function onMouseOver(rating: number) {
if (!disabled) {
setHoverRating(rating);
}
}
function onMouseOut(rating: number) {
if (!disabled && hoverRating === rating) {
setHoverRating(undefined);
}
}
function getClassName(rating: number) {
if (hoverRating && hoverRating >= rating) {
if (hoverRating === props.value) {
return "unsetting";
}
return "setting";
}
if (props.value && props.value >= rating) {
return "set";
}
return "unset";
}
function getTooltip(rating: number) {
if (disabled && props.value) {
// always return current rating for disabled control
return props.value.toString();
}
if (!disabled) {
return rating.toString();
}
}
const renderRatingButton = (rating: number) => (
<Button
disabled={disabled}
className="minimal"
onClick={() => setRating(rating)}
variant="secondary"
onMouseOver={() => onMouseOver(rating)}
onMouseOut={() => onMouseOut(rating)}
onFocus={() => onMouseOver(rating)}
onBlur={() => onMouseOut(rating)}
title={getTooltip(rating)}
key={`star-${rating}`}
>
<Icon icon={getIcon(rating)} className={getClassName(rating)} />
</Button>
);
const maxRating = 5;
return (
<div className="rating-stars align-middle">
{Array.from(Array(maxRating)).map((value, index) =>
renderRatingButton(index + 1)
)}
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { TagLink } from "src/components/Shared/TagLink";
import TruncatedText from "src/components/Shared/TruncatedText"; import TruncatedText from "src/components/Shared/TruncatedText";
import { PerformerCard } from "src/components/Performers/PerformerCard"; import { PerformerCard } from "src/components/Performers/PerformerCard";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { RatingStars } from "./RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
interface ISceneDetailProps { interface ISceneDetailProps {
@@ -99,10 +99,10 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
/> />
</h5> </h5>
) : undefined} ) : undefined}
{props.scene.rating ? ( {props.scene.rating100 ? (
<h6> <h6>
<FormattedMessage id="rating" />:{" "} <FormattedMessage id="rating" />:{" "}
<RatingStars value={props.scene.rating} /> <RatingSystem value={props.scene.rating100} disabled />
</h6> </h6>
) : ( ) : (
"" ""

View File

@@ -40,7 +40,7 @@ import queryString from "query-string";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox"; import { stashboxDisplayName } from "src/utils/stashbox";
import { SceneMovieTable } from "./SceneMovieTable"; import { SceneMovieTable } from "./SceneMovieTable";
import { RatingStars } from "./RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { import {
faSearch, faSearch,
faSyncAlt, faSyncAlt,
@@ -123,7 +123,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
director: yup.string().optional().nullable(), director: yup.string().optional().nullable(),
url: yup.string().optional().nullable(), url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(), date: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(), rating100: yup.number().optional().nullable(),
gallery_ids: yup.array(yup.string().required()).optional().nullable(), gallery_ids: yup.array(yup.string().required()).optional().nullable(),
studio_id: yup.string().optional().nullable(), studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -147,7 +147,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
director: scene.director ?? "", director: scene.director ?? "",
url: scene.url ?? "", url: scene.url ?? "",
date: scene.date ?? "", date: scene.date ?? "",
rating: scene.rating ?? null, rating100: scene.rating100 ?? null,
gallery_ids: (scene.galleries ?? []).map((g) => g.id), gallery_ids: (scene.galleries ?? []).map((g) => g.id),
studio_id: scene.studio?.id, studio_id: scene.studio?.id,
performer_ids: (scene.performers ?? []).map((p) => p.id), performer_ids: (scene.performers ?? []).map((p) => p.id),
@@ -171,7 +171,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
}); });
function setRating(v: number) { function setRating(v: number) {
formik.setFieldValue("rating", v); formik.setFieldValue("rating100", v);
} }
interface IGallerySelectValue { interface IGallerySelectValue {
@@ -206,11 +206,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
Mousetrap.bind("0", () => setRating(NaN)); Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1)); Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(2)); Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(3)); Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(4)); Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(5)); Mousetrap.bind("5", () => setRating(100));
setTimeout(() => { setTimeout(() => {
Mousetrap.unbind("0"); Mousetrap.unbind("0");
@@ -287,7 +287,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
input: { input: {
...updateValues, ...updateValues,
id: scene.id!, id: scene.id!,
rating: input.rating ?? null, rating100: input.rating100 ?? null,
}, },
}, },
}); });
@@ -799,10 +799,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={formik.values.rating ?? undefined} value={formik.values.rating100 ?? undefined}
onSetRating={(value) => onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null) formik.setFieldValue("rating100", value ?? null)
} }
/> />
</Col> </Col>

View File

@@ -90,7 +90,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
<h5>{title}</h5> <h5>{title}</h5>
</Link> </Link>
</td> </td>
<td>{scene.rating ? scene.rating : ""}</td> <td>{scene.rating100 ? scene.rating100 : ""}</td>
<td>{file?.duration && TextUtils.secondsToTimestamp(file.duration)}</td> <td>{file?.duration && TextUtils.secondsToTimestamp(file.duration)}</td>
<td>{renderTags(scene.tags)}</td> <td>{renderTags(scene.tags)}</td>
<td>{renderPerformers(scene.performers)}</td> <td>{renderPerformers(scene.performers)}</td>

View File

@@ -31,7 +31,7 @@ import {
ScrapedTagsRow, ScrapedTagsRow,
} from "./SceneDetails/SceneScrapeDialog"; } from "./SceneDetails/SceneScrapeDialog";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { RatingStars } from "./SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IStashIDsField { interface IStashIDsField {
values: GQL.StashId[]; values: GQL.StashId[];
@@ -66,7 +66,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
new ScrapeResult<string>(dest.date) new ScrapeResult<string>(dest.date)
); );
const [rating, setRating] = useState(new ScrapeResult<number>(dest.rating)); const [rating, setRating] = useState(
new ScrapeResult<number>(dest.rating100)
);
const [oCounter, setOCounter] = useState( const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter) new ScrapeResult<number>(dest.o_counter)
); );
@@ -194,9 +196,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
setRating( setRating(
new ScrapeResult( new ScrapeResult(
dest.rating, dest.rating100,
sources.find((s) => s.rating)?.rating, sources.find((s) => s.rating100)?.rating100,
!dest.rating !dest.rating100
) )
); );
@@ -324,10 +326,10 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={intl.formatMessage({ id: "rating" })} title={intl.formatMessage({ id: "rating" })}
result={rating} result={rating}
renderOriginalField={() => ( renderOriginalField={() => (
<RatingStars value={rating.originalValue} disabled /> <RatingSystem value={rating.originalValue} disabled />
)} )}
renderNewField={() => ( renderNewField={() => (
<RatingStars value={rating.newValue} disabled /> <RatingSystem value={rating.newValue} disabled />
)} )}
onChange={(value) => setRating(value)} onChange={(value) => setRating(value)}
/> />
@@ -430,7 +432,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title: title.getNewValue(), title: title.getNewValue(),
url: url.getNewValue(), url: url.getNewValue(),
date: date.getNewValue(), date: date.getNewValue(),
rating: rating.getNewValue(), rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(), o_counter: oCounter.getNewValue(),
gallery_ids: galleries.getNewValue(), gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue(), studio_id: studio.getNewValue(),

View File

@@ -479,37 +479,6 @@ input[type="range"].blue-slider {
} }
} }
.rating-stars {
display: inline-flex;
button {
font-size: inherit;
margin-right: 1px;
padding: 0;
&:hover {
background-color: inherit;
}
&:disabled {
background-color: inherit;
opacity: inherit;
}
}
.unsetting {
color: gold;
}
.setting {
color: gold;
}
.set {
color: gold;
}
}
#scene-edit-details { #scene-edit-details {
.rating-stars { .rating-stars {
font-size: 1.3em; font-size: 1.3em;
@@ -662,3 +631,7 @@ input[type="range"].blue-slider {
padding: 0.5rem; padding: 0.5rem;
} }
} }
.scrape-dialog .rating-number.disabled {
padding-left: 0.5em;
}

View File

@@ -24,6 +24,15 @@ import {
connectionStateLabel, connectionStateLabel,
InteractiveContext, InteractiveContext,
} from "src/hooks/Interactive/context"; } from "src/hooks/Interactive/context";
import {
defaultRatingStarPrecision,
defaultRatingSystemOptions,
defaultRatingSystemType,
RatingStarPrecision,
ratingStarPrecisionIntlMap,
ratingSystemIntlMap,
RatingSystemType,
} from "src/utils/rating";
const allMenuItems = [ const allMenuItems = [
{ id: "scenes", headingID: "scenes" }, { id: "scenes", headingID: "scenes" },
@@ -80,6 +89,24 @@ export const SettingsInterfacePanel: React.FC = () => {
}); });
} }
function saveRatingSystemType(t: RatingSystemType) {
saveUI({
ratingSystemOptions: {
...ui.ratingSystemOptions,
type: t,
},
});
}
function saveRatingSystemStarPrecision(p: RatingStarPrecision) {
saveUI({
ratingSystemOptions: {
...(ui.ratingSystemOptions ?? defaultRatingSystemOptions),
starPrecision: p,
},
});
}
if (error) return <h1>{error.message}</h1>; if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
@@ -415,6 +442,42 @@ export const SettingsInterfacePanel: React.FC = () => {
} }
/> />
</div> </div>
<SelectSetting
id="rating_system"
headingID="config.ui.editing.rating_system.type.label"
value={ui.ratingSystemOptions?.type ?? defaultRatingSystemType}
onChange={(v) => saveRatingSystemType(v as RatingSystemType)}
>
{Array.from(ratingSystemIntlMap.entries()).map((v) => (
<option key={v[0]} value={v[0]}>
{intl.formatMessage({
id: v[1],
})}
</option>
))}
</SelectSetting>
{(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) ===
RatingSystemType.Stars && (
<SelectSetting
id="rating_system_star_precision"
headingID="config.ui.editing.rating_system.star_precision.label"
value={
ui.ratingSystemOptions?.starPrecision ??
defaultRatingStarPrecision
}
onChange={(v) =>
saveRatingSystemStarPrecision(v as RatingStarPrecision)
}
>
{Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => (
<option key={v[0]} value={v[0]}>
{intl.formatMessage({
id: v[1],
})}
</option>
))}
</SelectSetting>
)}
</SettingSection> </SettingSection>
<SettingSection headingID="config.ui.custom_css.heading"> <SettingSection headingID="config.ui.custom_css.heading">

View File

@@ -0,0 +1,118 @@
import React, { useRef } from "react";
export interface IRatingNumberProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingNumber: React.FC<IRatingNumberProps> = (
props: IRatingNumberProps
) => {
const text = ((props.value ?? 0) / 10).toFixed(1);
const useValidation = useRef(true);
function stepChange() {
useValidation.current = false;
}
function nonStepChange() {
useValidation.current = true;
}
function setCursorPosition(
target: HTMLInputElement,
pos: number,
endPos?: number
) {
// This is a workaround to a missing feature where you can't set cursor position in input numbers.
// See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements
target.type = "text";
target.setSelectionRange(pos, endPos ?? pos);
target.type = "number";
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!props.onSetRating) {
return;
}
let val = e.target.value;
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
useValidation.current = true;
return;
}
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
if (match == null || props.onSetRating == null) {
return;
}
if (match[2] && !(match[2] == "0" && match[1] == "1")) {
match[2] = "";
}
if (match[4] == null || match[4] == "") {
match[4] = "0";
}
let value = match[1] + match[2] + "." + match[4];
e.target.value = value;
if (val.length > 0) {
if (Number(value) > 10) {
value = "10.0";
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
let cursorPosition = 0;
if (match[2] && !match[4]) {
cursorPosition = 3;
} else if (matchOld != null && match[1] !== matchOld[1]) {
cursorPosition = 2;
} else if (
matchOld != null &&
match[1] === matchOld[1] &&
match[2] === matchOld[2] &&
match[4] === matchOld[4]
) {
cursorPosition = 2;
}
setCursorPosition(e.target, cursorPosition);
}
}
if (props.disabled) {
return (
<div className="rating-number disabled">
<span>{Number((props.value ?? 0) / 10).toFixed(1)}</span>
</div>
);
} else {
return (
<div className="rating-number">
<input
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
onKeyDown={nonStepChange}
onChange={handleChange}
value={text}
min="0.0"
step="0.1"
max="10"
placeholder="0.0"
/>
</div>
);
}
};

Some files were not shown because too many files have changed in this diff Show More