mirror of https://github.com/stashapp/stash.git
Organised flag (#988)
* Add organized boolean to scene model (#729) * Add organized button to scene page * Add flag to galleries and images * Import/export changes * Make organized flag not null * Ignore organized scenes for autotag Co-authored-by: com1234 <com1234@notarealemail.com>
This commit is contained in:
parent
99bd7bc750
commit
aadbcaeec2
|
@ -7,6 +7,7 @@ fragment GallerySlimData on Gallery {
|
|||
url
|
||||
details
|
||||
rating
|
||||
organized
|
||||
image_count
|
||||
cover {
|
||||
...SlimImageData
|
||||
|
|
|
@ -7,6 +7,7 @@ fragment GalleryData on Gallery {
|
|||
url
|
||||
details
|
||||
rating
|
||||
organized
|
||||
images {
|
||||
...SlimImageData
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ fragment SlimImageData on Image {
|
|||
checksum
|
||||
title
|
||||
rating
|
||||
organized
|
||||
o_counter
|
||||
path
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ fragment ImageData on Image {
|
|||
checksum
|
||||
title
|
||||
rating
|
||||
organized
|
||||
o_counter
|
||||
path
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ fragment SlimSceneData on Scene {
|
|||
date
|
||||
rating
|
||||
o_counter
|
||||
organized
|
||||
path
|
||||
|
||||
file {
|
||||
|
|
|
@ -8,6 +8,7 @@ fragment SceneData on Scene {
|
|||
date
|
||||
rating
|
||||
o_counter
|
||||
organized
|
||||
path
|
||||
|
||||
file {
|
||||
|
|
|
@ -4,6 +4,7 @@ mutation GalleryCreate(
|
|||
$url: String,
|
||||
$date: String,
|
||||
$rating: Int,
|
||||
$organized: Boolean,
|
||||
$scene_id: ID,
|
||||
$studio_id: ID,
|
||||
$performer_ids: [ID!] = [],
|
||||
|
|
|
@ -70,6 +70,8 @@ input SceneFilterType {
|
|||
path: StringCriterionInput
|
||||
"""Filter by rating"""
|
||||
rating: IntCriterionInput
|
||||
"""Filter by organized"""
|
||||
organized: Boolean
|
||||
"""Filter by o-counter"""
|
||||
o_counter: IntCriterionInput
|
||||
"""Filter by resolution"""
|
||||
|
@ -117,6 +119,8 @@ input GalleryFilterType {
|
|||
is_zip: Boolean
|
||||
"""Filter by rating"""
|
||||
rating: IntCriterionInput
|
||||
"""Filter by organized"""
|
||||
organized: Boolean
|
||||
"""Filter by average image resolution"""
|
||||
average_resolution: ResolutionEnum
|
||||
"""Filter to only include scenes with this studio"""
|
||||
|
@ -145,6 +149,8 @@ input ImageFilterType {
|
|||
path: StringCriterionInput
|
||||
"""Filter by rating"""
|
||||
rating: IntCriterionInput
|
||||
"""Filter by organized"""
|
||||
organized: Boolean
|
||||
"""Filter by o-counter"""
|
||||
o_counter: IntCriterionInput
|
||||
"""Filter by resolution"""
|
||||
|
|
|
@ -8,6 +8,7 @@ type Gallery {
|
|||
date: String
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean!
|
||||
scene: Scene
|
||||
studio: Studio
|
||||
image_count: Int!
|
||||
|
@ -31,6 +32,7 @@ input GalleryCreateInput {
|
|||
date: String
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
|
@ -45,6 +47,7 @@ input GalleryUpdateInput {
|
|||
date: String
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
|
@ -58,6 +61,7 @@ input BulkGalleryUpdateInput {
|
|||
date: String
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
studio_id: ID
|
||||
tag_ids: BulkUpdateIds
|
||||
|
|
|
@ -4,6 +4,7 @@ type Image {
|
|||
title: String
|
||||
rating: Int
|
||||
o_counter: Int
|
||||
organized: Boolean!
|
||||
path: String!
|
||||
|
||||
file: ImageFileType! # Resolver
|
||||
|
@ -31,6 +32,7 @@ input ImageUpdateInput {
|
|||
id: ID!
|
||||
title: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
|
||||
studio_id: ID
|
||||
performer_ids: [ID!]
|
||||
|
@ -43,6 +45,7 @@ input BulkImageUpdateInput {
|
|||
ids: [ID!]
|
||||
title: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
|
||||
studio_id: ID
|
||||
performer_ids: BulkUpdateIds
|
||||
|
|
|
@ -32,6 +32,7 @@ type Scene {
|
|||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
organized: Boolean!
|
||||
o_counter: Int
|
||||
path: String!
|
||||
|
||||
|
@ -60,6 +61,7 @@ input SceneUpdateInput {
|
|||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
performer_ids: [ID!]
|
||||
|
@ -89,6 +91,7 @@ input BulkSceneUpdateInput {
|
|||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
performer_ids: BulkUpdateIds
|
||||
|
|
|
@ -205,6 +205,7 @@ func (r *mutationResolver) galleryUpdate(input models.GalleryUpdateInput, transl
|
|||
updatedGallery.Date = translator.sqliteDate(input.Date, "date")
|
||||
updatedGallery.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedGallery.Organized = input.Organized
|
||||
|
||||
// gallery scene is set from the scene only
|
||||
|
||||
|
@ -272,6 +273,7 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input models.B
|
|||
updatedGallery.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedGallery.SceneID = translator.nullInt64FromString(input.SceneID, "scene_id")
|
||||
updatedGallery.Organized = input.Organized
|
||||
|
||||
ret := []*models.Gallery{}
|
||||
|
||||
|
|
|
@ -77,6 +77,7 @@ func (r *mutationResolver) imageUpdate(input models.ImageUpdateInput, translator
|
|||
updatedImage.Title = translator.nullString(input.Title, "title")
|
||||
updatedImage.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedImage.Organized = input.Organized
|
||||
|
||||
qb := models.NewImageQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
@ -142,6 +143,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input models.Bul
|
|||
updatedImage.Title = translator.nullString(input.Title, "title")
|
||||
updatedImage.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedImage.Organized = input.Organized
|
||||
|
||||
ret := []*models.Image{}
|
||||
|
||||
|
|
|
@ -85,6 +85,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
|
|||
updatedScene.Date = translator.sqliteDate(input.Date, "date")
|
||||
updatedScene.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedScene.Organized = input.Organized
|
||||
|
||||
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||
var err error
|
||||
|
@ -242,6 +243,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
|
|||
updatedScene.Date = translator.sqliteDate(input.Date, "date")
|
||||
updatedScene.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedScene.Organized = input.Organized
|
||||
|
||||
ret := []*models.Scene{}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import (
|
|||
|
||||
var DB *sqlx.DB
|
||||
var dbPath string
|
||||
var appSchemaVersion uint = 15
|
||||
var appSchemaVersion uint = 16
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
const sqlite3Driver = "sqlite3ex"
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE `scenes` ADD COLUMN `organized` boolean not null default '0';
|
||||
ALTER TABLE `images` ADD COLUMN `organized` boolean not null default '0';
|
||||
ALTER TABLE `galleries` ADD COLUMN `organized` boolean not null default '0';
|
|
@ -40,6 +40,8 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
|
|||
newGalleryJSON.Rating = int(gallery.Rating.Int64)
|
||||
}
|
||||
|
||||
newGalleryJSON.Organized = gallery.Organized
|
||||
|
||||
if gallery.Details.Valid {
|
||||
newGalleryJSON.Details = gallery.Details.String
|
||||
}
|
||||
|
|
|
@ -25,14 +25,15 @@ const (
|
|||
)
|
||||
|
||||
const (
|
||||
path = "path"
|
||||
zip = true
|
||||
url = "url"
|
||||
checksum = "checksum"
|
||||
title = "title"
|
||||
date = "2001-01-01"
|
||||
rating = 5
|
||||
details = "details"
|
||||
path = "path"
|
||||
zip = true
|
||||
url = "url"
|
||||
checksum = "checksum"
|
||||
title = "title"
|
||||
date = "2001-01-01"
|
||||
rating = 5
|
||||
organized = true
|
||||
details = "details"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -58,9 +59,10 @@ func createFullGallery(id int) models.Gallery {
|
|||
String: date,
|
||||
Valid: true,
|
||||
},
|
||||
Details: modelstest.NullString(details),
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
URL: modelstest.NullString(url),
|
||||
Details: modelstest.NullString(details),
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
Organized: organized,
|
||||
URL: modelstest.NullString(url),
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createTime,
|
||||
},
|
||||
|
@ -84,14 +86,15 @@ func createEmptyGallery(id int) models.Gallery {
|
|||
|
||||
func createFullJSONGallery() *jsonschema.Gallery {
|
||||
return &jsonschema.Gallery{
|
||||
Title: title,
|
||||
Path: path,
|
||||
Zip: zip,
|
||||
Checksum: checksum,
|
||||
Date: date,
|
||||
Details: details,
|
||||
Rating: rating,
|
||||
URL: url,
|
||||
Title: title,
|
||||
Path: path,
|
||||
Zip: zip,
|
||||
Checksum: checksum,
|
||||
Date: date,
|
||||
Details: details,
|
||||
Rating: rating,
|
||||
Organized: organized,
|
||||
URL: url,
|
||||
CreatedAt: models.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
|
|
|
@ -68,6 +68,7 @@ func (i *Importer) galleryJSONToGallery(galleryJSON jsonschema.Gallery) models.G
|
|||
newGallery.Rating = sql.NullInt64{Int64: int64(galleryJSON.Rating), Valid: true}
|
||||
}
|
||||
|
||||
newGallery.Organized = galleryJSON.Organized
|
||||
newGallery.CreatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.CreatedAt.GetTime()}
|
||||
newGallery.UpdatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.UpdatedAt.GetTime()}
|
||||
|
||||
|
|
|
@ -56,13 +56,14 @@ func TestImporterName(t *testing.T) {
|
|||
func TestImporterPreImport(t *testing.T) {
|
||||
i := Importer{
|
||||
Input: jsonschema.Gallery{
|
||||
Path: path,
|
||||
Checksum: checksum,
|
||||
Title: title,
|
||||
Date: date,
|
||||
Details: details,
|
||||
Rating: rating,
|
||||
URL: url,
|
||||
Path: path,
|
||||
Checksum: checksum,
|
||||
Title: title,
|
||||
Date: date,
|
||||
Details: details,
|
||||
Rating: rating,
|
||||
Organized: organized,
|
||||
URL: url,
|
||||
CreatedAt: models.JSONTime{
|
||||
Time: createdAt,
|
||||
},
|
||||
|
@ -83,9 +84,10 @@ func TestImporterPreImport(t *testing.T) {
|
|||
String: date,
|
||||
Valid: true,
|
||||
},
|
||||
Details: modelstest.NullString(details),
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
URL: modelstest.NullString(url),
|
||||
Details: modelstest.NullString(details),
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
Organized: organized,
|
||||
URL: modelstest.NullString(url),
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createdAt,
|
||||
},
|
||||
|
|
|
@ -23,6 +23,7 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image {
|
|||
newImageJSON.Rating = int(image.Rating.Int64)
|
||||
}
|
||||
|
||||
newImageJSON.Organized = image.Organized
|
||||
newImageJSON.OCounter = image.OCounter
|
||||
|
||||
newImageJSON.File = getImageFileJSON(image)
|
||||
|
|
|
@ -39,13 +39,14 @@ const (
|
|||
)
|
||||
|
||||
const (
|
||||
checksum = "checksum"
|
||||
title = "title"
|
||||
rating = 5
|
||||
ocounter = 2
|
||||
size = 123
|
||||
width = 100
|
||||
height = 100
|
||||
checksum = "checksum"
|
||||
title = "title"
|
||||
rating = 5
|
||||
organized = true
|
||||
ocounter = 2
|
||||
size = 123
|
||||
width = 100
|
||||
height = 100
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -63,14 +64,15 @@ var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)
|
|||
|
||||
func createFullImage(id int) models.Image {
|
||||
return models.Image{
|
||||
ID: id,
|
||||
Title: modelstest.NullString(title),
|
||||
Checksum: checksum,
|
||||
Height: modelstest.NullInt64(height),
|
||||
OCounter: ocounter,
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
Size: modelstest.NullInt64(int64(size)),
|
||||
Width: modelstest.NullInt64(width),
|
||||
ID: id,
|
||||
Title: modelstest.NullString(title),
|
||||
Checksum: checksum,
|
||||
Height: modelstest.NullInt64(height),
|
||||
OCounter: ocounter,
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
Organized: organized,
|
||||
Size: modelstest.NullInt64(int64(size)),
|
||||
Width: modelstest.NullInt64(width),
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createTime,
|
||||
},
|
||||
|
@ -94,10 +96,11 @@ func createEmptyImage(id int) models.Image {
|
|||
|
||||
func createFullJSONImage() *jsonschema.Image {
|
||||
return &jsonschema.Image{
|
||||
Title: title,
|
||||
Checksum: checksum,
|
||||
OCounter: ocounter,
|
||||
Rating: rating,
|
||||
Title: title,
|
||||
Checksum: checksum,
|
||||
OCounter: ocounter,
|
||||
Rating: rating,
|
||||
Organized: organized,
|
||||
File: &jsonschema.ImageFile{
|
||||
Height: height,
|
||||
Size: size,
|
||||
|
|
|
@ -63,6 +63,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
|
|||
newImage.Rating = sql.NullInt64{Int64: int64(imageJSON.Rating), Valid: true}
|
||||
}
|
||||
|
||||
newImage.Organized = imageJSON.Organized
|
||||
newImage.OCounter = imageJSON.OCounter
|
||||
newImage.CreatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.CreatedAt.GetTime()}
|
||||
newImage.UpdatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.UpdatedAt.GetTime()}
|
||||
|
|
|
@ -17,6 +17,7 @@ type Gallery struct {
|
|||
Date string `json:"date,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
|
|
@ -20,6 +20,7 @@ type Image struct {
|
|||
Checksum string `json:"checksum,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
OCounter int `json:"o_counter,omitempty"`
|
||||
Galleries []string `json:"galleries,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
|
|
|
@ -43,6 +43,7 @@ type Scene struct {
|
|||
URL string `json:"url,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
OCounter int `json:"o_counter,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Gallery string `json:"gallery,omitempty"`
|
||||
|
|
|
@ -37,7 +37,8 @@ func (t *AutoTagPerformerTask) autoTagPerformer() {
|
|||
|
||||
regex := getQueryRegex(t.performer.Name.String)
|
||||
|
||||
scenes, err := qb.QueryAllByPathRegex(regex)
|
||||
const ignoreOrganized = true
|
||||
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
|
||||
|
||||
if err != nil {
|
||||
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||
|
@ -82,7 +83,8 @@ func (t *AutoTagStudioTask) autoTagStudio() {
|
|||
|
||||
regex := getQueryRegex(t.studio.Name.String)
|
||||
|
||||
scenes, err := qb.QueryAllByPathRegex(regex)
|
||||
const ignoreOrganized = true
|
||||
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
|
||||
|
||||
if err != nil {
|
||||
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||
|
@ -139,7 +141,8 @@ func (t *AutoTagTagTask) autoTagTag() {
|
|||
|
||||
regex := getQueryRegex(t.tag.Name)
|
||||
|
||||
scenes, err := qb.QueryAllByPathRegex(regex)
|
||||
const ignoreOrganized = true
|
||||
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
|
||||
|
||||
if err != nil {
|
||||
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())
|
||||
|
|
|
@ -187,6 +187,16 @@ func createScenes(tx *sqlx.Tx) error {
|
|||
}
|
||||
}
|
||||
|
||||
// add organized scenes
|
||||
for _, fn := range scenePatterns {
|
||||
s := makeScene("organized"+fn, false)
|
||||
s.Organized = true
|
||||
err := createScene(sqb, tx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// create scene with existing studio io
|
||||
studioScene := makeScene(existingStudioSceneName, true)
|
||||
studioScene.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)}
|
||||
|
|
|
@ -14,6 +14,7 @@ type Gallery struct {
|
|||
Date SQLiteDate `db:"date" json:"date"`
|
||||
Details sql.NullString `db:"details" json:"details"`
|
||||
Rating sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized bool `db:"organized" json:"organized"`
|
||||
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
|
||||
SceneID sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
|
||||
FileModTime NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`
|
||||
|
@ -32,6 +33,7 @@ type GalleryPartial struct {
|
|||
Date *SQLiteDate `db:"date" json:"date"`
|
||||
Details *sql.NullString `db:"details" json:"details"`
|
||||
Rating *sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized *bool `db:"organized" json:"organized"`
|
||||
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
|
||||
SceneID *sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
|
||||
FileModTime *NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`
|
||||
|
|
|
@ -11,6 +11,7 @@ type Image struct {
|
|||
Path string `db:"path" json:"path"`
|
||||
Title sql.NullString `db:"title" json:"title"`
|
||||
Rating sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized bool `db:"organized" json:"organized"`
|
||||
OCounter int `db:"o_counter" json:"o_counter"`
|
||||
Size sql.NullInt64 `db:"size" json:"size"`
|
||||
Width sql.NullInt64 `db:"width" json:"width"`
|
||||
|
@ -29,6 +30,7 @@ type ImagePartial struct {
|
|||
Path *string `db:"path" json:"path"`
|
||||
Title *sql.NullString `db:"title" json:"title"`
|
||||
Rating *sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized *bool `db:"organized" json:"organized"`
|
||||
Size *sql.NullInt64 `db:"size" json:"size"`
|
||||
Width *sql.NullInt64 `db:"width" json:"width"`
|
||||
Height *sql.NullInt64 `db:"height" json:"height"`
|
||||
|
|
|
@ -16,6 +16,7 @@ type Scene struct {
|
|||
URL sql.NullString `db:"url" json:"url"`
|
||||
Date SQLiteDate `db:"date" json:"date"`
|
||||
Rating sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized bool `db:"organized" json:"organized"`
|
||||
OCounter int `db:"o_counter" json:"o_counter"`
|
||||
Size sql.NullString `db:"size" json:"size"`
|
||||
Duration sql.NullFloat64 `db:"duration" json:"duration"`
|
||||
|
@ -44,6 +45,7 @@ type ScenePartial struct {
|
|||
URL *sql.NullString `db:"url" json:"url"`
|
||||
Date *SQLiteDate `db:"date" json:"date"`
|
||||
Rating *sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized *bool `db:"organized" json:"organized"`
|
||||
Size *sql.NullString `db:"size" json:"size"`
|
||||
Duration *sql.NullFloat64 `db:"duration" json:"duration"`
|
||||
VideoCodec *sql.NullString `db:"video_codec" json:"video_codec"`
|
||||
|
|
|
@ -15,3 +15,10 @@ func NullInt64(v int64) sql.NullInt64 {
|
|||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NullBool(v bool) sql.NullBool {
|
||||
return sql.NullBool{
|
||||
Bool: v,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,8 +21,8 @@ func NewGalleryQueryBuilder() GalleryQueryBuilder {
|
|||
func (qb *GalleryQueryBuilder) Create(newGallery Gallery, tx *sqlx.Tx) (*Gallery, error) {
|
||||
ensureTx(tx)
|
||||
result, err := tx.NamedExec(
|
||||
`INSERT INTO galleries (path, checksum, zip, title, date, details, url, studio_id, rating, scene_id, file_mod_time, created_at, updated_at)
|
||||
VALUES (:path, :checksum, :zip, :title, :date, :details, :url, :studio_id, :rating, :scene_id, :file_mod_time, :created_at, :updated_at)
|
||||
`INSERT INTO galleries (path, checksum, zip, title, date, details, url, studio_id, rating, organized, scene_id, file_mod_time, created_at, updated_at)
|
||||
VALUES (:path, :checksum, :zip, :title, :date, :details, :url, :studio_id, :rating, :organized, :scene_id, :file_mod_time, :created_at, :updated_at)
|
||||
`,
|
||||
newGallery,
|
||||
)
|
||||
|
@ -232,6 +232,16 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
|
|||
query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating")
|
||||
qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution)
|
||||
|
||||
if Organized := galleryFilter.Organized; Organized != nil {
|
||||
var organized string
|
||||
if *Organized == true {
|
||||
organized = "1"
|
||||
} else {
|
||||
organized = "0"
|
||||
}
|
||||
query.addWhere("galleries.organized = " + organized)
|
||||
}
|
||||
|
||||
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
case "scene":
|
||||
|
|
|
@ -61,9 +61,9 @@ func NewImageQueryBuilder() ImageQueryBuilder {
|
|||
func (qb *ImageQueryBuilder) Create(newImage Image, tx *sqlx.Tx) (*Image, error) {
|
||||
ensureTx(tx)
|
||||
result, err := tx.NamedExec(
|
||||
`INSERT INTO images (checksum, path, title, rating, o_counter, size,
|
||||
`INSERT INTO images (checksum, path, title, rating, organized, o_counter, size,
|
||||
width, height, studio_id, file_mod_time, created_at, updated_at)
|
||||
VALUES (:checksum, :path, :title, :rating, :o_counter, :size,
|
||||
VALUES (:checksum, :path, :title, :rating, :organized, :o_counter, :size,
|
||||
:width, :height, :studio_id, :file_mod_time, :created_at, :updated_at)
|
||||
`,
|
||||
newImage,
|
||||
|
@ -309,6 +309,16 @@ func (qb *ImageQueryBuilder) Query(imageFilter *ImageFilterType, findFilter *Fin
|
|||
}
|
||||
}
|
||||
|
||||
if Organized := imageFilter.Organized; Organized != nil {
|
||||
var organized string
|
||||
if *Organized == true {
|
||||
organized = "1"
|
||||
} else {
|
||||
organized = "0"
|
||||
}
|
||||
query.addWhere("images.organized = " + organized)
|
||||
}
|
||||
|
||||
if resolutionFilter := imageFilter.Resolution; resolutionFilter != nil {
|
||||
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
|
||||
switch resolution {
|
||||
|
|
|
@ -59,9 +59,9 @@ func NewSceneQueryBuilder() SceneQueryBuilder {
|
|||
func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error) {
|
||||
ensureTx(tx)
|
||||
result, err := tx.NamedExec(
|
||||
`INSERT INTO scenes (oshash, checksum, path, title, details, url, date, rating, o_counter, size, duration, video_codec,
|
||||
`INSERT INTO scenes (oshash, checksum, path, title, details, url, date, rating, organized, o_counter, size, duration, video_codec,
|
||||
audio_codec, format, width, height, framerate, bitrate, studio_id, file_mod_time, created_at, updated_at)
|
||||
VALUES (:oshash, :checksum, :path, :title, :details, :url, :date, :rating, :o_counter, :size, :duration, :video_codec,
|
||||
VALUES (:oshash, :checksum, :path, :title, :details, :url, :date, :rating, :organized, :o_counter, :size, :duration, :video_codec,
|
||||
:audio_codec, :format, :width, :height, :framerate, :bitrate, :studio_id, :file_mod_time, :created_at, :updated_at)
|
||||
`,
|
||||
newScene,
|
||||
|
@ -325,6 +325,16 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
|
|||
query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating")
|
||||
query.handleIntCriterionInput(sceneFilter.OCounter, "scenes.o_counter")
|
||||
|
||||
if Organized := sceneFilter.Organized; Organized != nil {
|
||||
var organized string
|
||||
if *Organized == true {
|
||||
organized = "1"
|
||||
} else {
|
||||
organized = "0"
|
||||
}
|
||||
query.addWhere("scenes.organized = " + organized)
|
||||
}
|
||||
|
||||
if durationFilter := sceneFilter.Duration; durationFilter != nil {
|
||||
clause, thisArgs := getDurationWhereClause(*durationFilter)
|
||||
query.addWhere(clause)
|
||||
|
@ -473,10 +483,14 @@ func getDurationWhereClause(durationFilter IntCriterionInput) (string, []interfa
|
|||
return clause, args
|
||||
}
|
||||
|
||||
func (qb *SceneQueryBuilder) QueryAllByPathRegex(regex string) ([]*Scene, error) {
|
||||
func (qb *SceneQueryBuilder) QueryAllByPathRegex(regex string, ignoreOrganized bool) ([]*Scene, error) {
|
||||
var args []interface{}
|
||||
body := selectDistinctIDs("scenes") + " WHERE scenes.path regexp ?"
|
||||
|
||||
if ignoreOrganized {
|
||||
body += " AND scenes.organized = 0"
|
||||
}
|
||||
|
||||
args = append(args, "(?i)"+regex)
|
||||
|
||||
idsResult, err := runIdsQuery(body, args)
|
||||
|
|
|
@ -43,6 +43,7 @@ func ToBasicJSON(reader models.SceneReader, scene *models.Scene) (*jsonschema.Sc
|
|||
newSceneJSON.Rating = int(scene.Rating.Int64)
|
||||
}
|
||||
|
||||
newSceneJSON.Organized = scene.Organized
|
||||
newSceneJSON.OCounter = scene.OCounter
|
||||
|
||||
if scene.Details.Valid {
|
||||
|
|
|
@ -47,6 +47,7 @@ const (
|
|||
date = "2001-01-01"
|
||||
rating = 5
|
||||
ocounter = 2
|
||||
organized = true
|
||||
details = "details"
|
||||
size = "size"
|
||||
duration = 1.23
|
||||
|
@ -113,6 +114,7 @@ func createFullScene(id int) models.Scene {
|
|||
OCounter: ocounter,
|
||||
OSHash: modelstest.NullString(oshash),
|
||||
Rating: modelstest.NullInt64(rating),
|
||||
Organized: organized,
|
||||
Size: modelstest.NullString(size),
|
||||
VideoCodec: modelstest.NullString(videoCodec),
|
||||
Width: modelstest.NullInt64(width),
|
||||
|
@ -140,14 +142,15 @@ func createEmptyScene(id int) models.Scene {
|
|||
|
||||
func createFullJSONScene(image string) *jsonschema.Scene {
|
||||
return &jsonschema.Scene{
|
||||
Title: title,
|
||||
Checksum: checksum,
|
||||
Date: date,
|
||||
Details: details,
|
||||
OCounter: ocounter,
|
||||
OSHash: oshash,
|
||||
Rating: rating,
|
||||
URL: url,
|
||||
Title: title,
|
||||
Checksum: checksum,
|
||||
Date: date,
|
||||
Details: details,
|
||||
OCounter: ocounter,
|
||||
OSHash: oshash,
|
||||
Rating: rating,
|
||||
Organized: organized,
|
||||
URL: url,
|
||||
File: &jsonschema.SceneFile{
|
||||
AudioCodec: audioCodec,
|
||||
Bitrate: bitrate,
|
||||
|
|
|
@ -90,6 +90,7 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene {
|
|||
newScene.Rating = sql.NullInt64{Int64: int64(sceneJSON.Rating), Valid: true}
|
||||
}
|
||||
|
||||
newScene.Organized = sceneJSON.Organized
|
||||
newScene.OCounter = sceneJSON.OCounter
|
||||
newScene.CreatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.CreatedAt.GetTime()}
|
||||
newScene.UpdatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.UpdatedAt.GetTime()}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
### ✨ New Features
|
||||
* Add organized flag for scenes, galleries and images.
|
||||
* Allow configuration of visible navbar items.
|
||||
|
||||
### 🎨 Improvements
|
||||
|
|
|
@ -28,12 +28,15 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
|
||||
const [updateGalleries] = useBulkGalleryUpdate();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
function makeBulkUpdateIds(
|
||||
ids: string[],
|
||||
mode: GQL.BulkUpdateIdMode
|
||||
|
@ -119,6 +122,10 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
||||
}
|
||||
|
||||
if (organized !== undefined) {
|
||||
galleryInput.organized = organized;
|
||||
}
|
||||
|
||||
return galleryInput;
|
||||
}
|
||||
|
||||
|
@ -223,6 +230,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((gallery: GQL.GallerySlimDataFragment) => {
|
||||
|
@ -238,6 +246,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
updateStudioID = GalleriestudioID;
|
||||
updatePerformerIds = galleryPerformerIDs;
|
||||
updateTagIds = galleryTagIDs;
|
||||
updateOrganized = gallery.organized;
|
||||
first = false;
|
||||
} else {
|
||||
if (galleryRating !== updateRating) {
|
||||
|
@ -252,6 +261,9 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
if (!_.isEqual(galleryTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (gallery.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -264,8 +276,16 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
if (tagMode === GQL.BulkUpdateIdMode.Set) {
|
||||
setTagIds(updateTagIds);
|
||||
}
|
||||
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected, performerMode, tagMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function renderMultiSelect(
|
||||
type: "performers" | "tags",
|
||||
ids: string[] | undefined
|
||||
|
@ -311,6 +331,16 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<Modal
|
||||
|
@ -359,10 +389,20 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>Tags</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Organized"
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -104,11 +104,24 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scene ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
|
@ -117,6 +130,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderScenePopoverButton()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useHistory, Link } from "react-router-dom";
|
||||
import { useFindGallery } from "src/core/StashService";
|
||||
import { useFindGallery, useGalleryUpdate } from "src/core/StashService";
|
||||
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
|
||||
import { TextUtils } from "src/utils";
|
||||
import * as Mousetrap from "mousetrap";
|
||||
import { useToast } from "src/hooks";
|
||||
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
|
||||
import { GalleryEditPanel } from "./GalleryEditPanel";
|
||||
import { GalleryDetailPanel } from "./GalleryDetailPanel";
|
||||
import { DeleteGalleriesDialog } from "../DeleteGalleriesDialog";
|
||||
|
@ -20,6 +22,7 @@ interface IGalleryParams {
|
|||
export const Gallery: React.FC = () => {
|
||||
const { tab = "images", id = "new" } = useParams<IGalleryParams>();
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const isNew = id === "new";
|
||||
|
||||
const { data, error, loading } = useFindGallery(id);
|
||||
|
@ -34,6 +37,28 @@ export const Gallery: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const [updateGallery] = useGalleryUpdate();
|
||||
|
||||
const [organizedLoading, setOrganizedLoading] = useState(false);
|
||||
|
||||
const onOrganizedClick = async () => {
|
||||
try {
|
||||
setOrganizedLoading(true);
|
||||
await updateGallery({
|
||||
variables: {
|
||||
input: {
|
||||
id: gallery?.id ?? "",
|
||||
organized: !gallery?.organized,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setOrganizedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
|
@ -103,7 +128,14 @@ export const Gallery: React.FC = () => {
|
|||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-edit-panel">Edit</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className="ml-auto">{renderOperations()}</Nav.Item>
|
||||
<Nav.Item className="ml-auto">
|
||||
<OrganizedButton
|
||||
loading={organizedLoading}
|
||||
organized={gallery.organized}
|
||||
onClick={onOrganizedClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>{renderOperations()}</Nav.Item>
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,12 +28,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
|
||||
const [updateImages] = useBulkImageUpdate();
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
function makeBulkUpdateIds(
|
||||
ids: string[],
|
||||
mode: GQL.BulkUpdateIdMode
|
||||
|
@ -119,6 +122,10 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
||||
}
|
||||
|
||||
if (organized !== undefined) {
|
||||
imageInput.organized = organized;
|
||||
}
|
||||
|
||||
return imageInput;
|
||||
}
|
||||
|
||||
|
@ -221,6 +228,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((image: GQL.SlimImageDataFragment) => {
|
||||
|
@ -236,6 +244,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
updateStudioID = imageStudioID;
|
||||
updatePerformerIds = imagePerformerIDs;
|
||||
updateTagIds = imageTagIDs;
|
||||
updateOrganized = image.organized;
|
||||
first = false;
|
||||
} else {
|
||||
if (imageRating !== updateRating) {
|
||||
|
@ -250,6 +259,9 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
if (!_.isEqual(imageTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (image.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -262,8 +274,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
if (tagMode === GQL.BulkUpdateIdMode.Set) {
|
||||
setTagIds(updateTagIds);
|
||||
}
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected, performerMode, tagMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function renderMultiSelect(
|
||||
type: "performers" | "tags",
|
||||
ids: string[] | undefined
|
||||
|
@ -309,6 +328,16 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<Modal
|
||||
|
@ -357,10 +386,20 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>Tags</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Organized"
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -93,11 +93,24 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
}
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.image.organized) {
|
||||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.image.tags.length > 0 ||
|
||||
props.image.performers.length > 0 ||
|
||||
props.image?.o_counter
|
||||
props.image.o_counter ||
|
||||
props.image.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
|
@ -106,6 +119,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -6,12 +6,14 @@ import {
|
|||
useImageIncrementO,
|
||||
useImageDecrementO,
|
||||
useImageResetO,
|
||||
useImageUpdate,
|
||||
} from "src/core/StashService";
|
||||
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import * as Mousetrap from "mousetrap";
|
||||
import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton";
|
||||
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
|
||||
import { ImageFileInfoPanel } from "./ImageFileInfoPanel";
|
||||
import { ImageEditPanel } from "./ImageEditPanel";
|
||||
import { ImageDetailPanel } from "./ImageDetailPanel";
|
||||
|
@ -33,10 +35,32 @@ export const Image: React.FC = () => {
|
|||
const [decrementO] = useImageDecrementO(image?.id ?? "0");
|
||||
const [resetO] = useImageResetO(image?.id ?? "0");
|
||||
|
||||
const [updateImage] = useImageUpdate();
|
||||
|
||||
const [organizedLoading, setOrganizedLoading] = useState(false);
|
||||
|
||||
const [activeTabKey, setActiveTabKey] = useState("image-details-panel");
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
const onOrganizedClick = async () => {
|
||||
try {
|
||||
setOrganizedLoading(true);
|
||||
await updateImage({
|
||||
variables: {
|
||||
input: {
|
||||
id: image?.id ?? "",
|
||||
organized: !image?.organized,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setOrganizedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onIncrementClick = async () => {
|
||||
try {
|
||||
setOLoading(true);
|
||||
|
@ -139,6 +163,13 @@ export const Image: React.FC = () => {
|
|||
onReset={onResetClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<OrganizedButton
|
||||
loading={organizedLoading}
|
||||
organized={image.organized}
|
||||
onClick={onOrganizedClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>{renderOperations()}</Nav.Item>
|
||||
</Nav>
|
||||
</div>
|
||||
|
|
|
@ -28,12 +28,15 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [organized, setOrganized] = useState<boolean | undefined>();
|
||||
|
||||
const [updateScenes] = useBulkSceneUpdate(getSceneInput());
|
||||
|
||||
// Network state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
function makeBulkUpdateIds(
|
||||
ids: string[],
|
||||
mode: GQL.BulkUpdateIdMode
|
||||
|
@ -119,6 +122,10 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
||||
}
|
||||
|
||||
if (organized !== undefined) {
|
||||
sceneInput.organized = organized;
|
||||
}
|
||||
|
||||
return sceneInput;
|
||||
}
|
||||
|
||||
|
@ -217,6 +224,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
let updateStudioID: string | undefined;
|
||||
let updatePerformerIds: string[] = [];
|
||||
let updateTagIds: string[] = [];
|
||||
let updateOrganized: boolean | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((scene: GQL.SlimSceneDataFragment) => {
|
||||
|
@ -233,6 +241,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
updatePerformerIds = scenePerformerIDs;
|
||||
updateTagIds = sceneTagIDs;
|
||||
first = false;
|
||||
updateOrganized = scene.organized;
|
||||
} else {
|
||||
if (sceneRating !== updateRating) {
|
||||
updateRating = undefined;
|
||||
|
@ -246,6 +255,9 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
if (!_.isEqual(sceneTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (scene.organized !== updateOrganized) {
|
||||
updateOrganized = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -258,8 +270,15 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
if (tagMode === GQL.BulkUpdateIdMode.Set) {
|
||||
setTagIds(updateTagIds);
|
||||
}
|
||||
setOrganized(updateOrganized);
|
||||
}, [props.selected, performerMode, tagMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = organized === undefined;
|
||||
}
|
||||
}, [organized, checkboxRef]);
|
||||
|
||||
function renderMultiSelect(
|
||||
type: "performers" | "tags",
|
||||
ids: string[] | undefined
|
||||
|
@ -305,6 +324,16 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
function cycleOrganized() {
|
||||
if (organized) {
|
||||
setOrganized(undefined);
|
||||
} else if (organized === undefined) {
|
||||
setOrganized(false);
|
||||
} else {
|
||||
setOrganized(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<Modal
|
||||
|
@ -353,10 +382,20 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
|
|||
{renderMultiSelect("performers", performerIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="performers">
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>Tags</Form.Label>
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="organized">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Organized"
|
||||
checked={organized}
|
||||
ref={checkboxRef}
|
||||
onChange={() => cycleOrganized()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -267,6 +267,18 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
}
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.scene.organized) {
|
||||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.scene.tags.length > 0 ||
|
||||
|
@ -274,7 +286,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
props.scene.movies.length > 0 ||
|
||||
props.scene.scene_markers.length > 0 ||
|
||||
props.scene?.o_counter ||
|
||||
props.scene.gallery
|
||||
props.scene.gallery ||
|
||||
props.scene.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
|
@ -286,6 +299,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
{maybeRenderSceneMarkerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderGallery()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
import cx from "classnames";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { defineMessages, useIntl } from "react-intl";
|
||||
|
||||
export interface IOrganizedButtonProps {
|
||||
loading: boolean;
|
||||
organized: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const OrganizedButton: React.FC<IOrganizedButtonProps> = (
|
||||
props: IOrganizedButtonProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const messages = defineMessages({
|
||||
organized: {
|
||||
id: "organized",
|
||||
defaultMessage: "Organized",
|
||||
},
|
||||
});
|
||||
|
||||
if (props.loading) return <Spinner animation="border" role="status" />;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={intl.formatMessage(messages.organized)}
|
||||
className={cx(
|
||||
"minimal",
|
||||
"organized-button",
|
||||
props.organized ? "organized" : "not-organized"
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -10,6 +10,7 @@ import {
|
|||
useSceneResetO,
|
||||
useSceneStreams,
|
||||
useSceneGenerateScreenshot,
|
||||
useSceneUpdate,
|
||||
} from "src/core/StashService";
|
||||
import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
|
||||
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
|
||||
|
@ -26,6 +27,7 @@ import { SceneMoviePanel } from "./SceneMoviePanel";
|
|||
import { DeleteScenesDialog } from "../DeleteScenesDialog";
|
||||
import { SceneGenerateDialog } from "../SceneGenerateDialog";
|
||||
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
|
||||
import { OrganizedButton } from "./OrganizedButton";
|
||||
|
||||
interface ISceneParams {
|
||||
id?: string;
|
||||
|
@ -36,6 +38,7 @@ export const Scene: React.FC = () => {
|
|||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const [updateScene] = useSceneUpdate();
|
||||
const [generateScreenshot] = useSceneGenerateScreenshot();
|
||||
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
@ -52,6 +55,8 @@ export const Scene: React.FC = () => {
|
|||
const [decrementO] = useSceneDecrementO(scene?.id ?? "0");
|
||||
const [resetO] = useSceneResetO(scene?.id ?? "0");
|
||||
|
||||
const [organizedLoading, setOrganizedLoading] = useState(false);
|
||||
|
||||
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
@ -69,6 +74,24 @@ export const Scene: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
const onOrganizedClick = async () => {
|
||||
try {
|
||||
setOrganizedLoading(true);
|
||||
await updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
id: scene?.id ?? "",
|
||||
organized: !scene?.organized,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setOrganizedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onIncrementClick = async () => {
|
||||
try {
|
||||
setOLoading(true);
|
||||
|
@ -246,6 +269,13 @@ export const Scene: React.FC = () => {
|
|||
onReset={onResetClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<OrganizedButton
|
||||
loading={organizedLoading}
|
||||
organized={scene.organized}
|
||||
onClick={onOrganizedClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>{renderOperations()}</Nav.Item>
|
||||
</ButtonGroup>
|
||||
</Nav>
|
||||
|
|
|
@ -70,7 +70,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [updateScene] = useSceneUpdate(getSceneInput());
|
||||
const [updateScene] = useSceneUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isVisible) {
|
||||
|
@ -230,7 +230,11 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateScene();
|
||||
const result = await updateScene({
|
||||
variables: {
|
||||
input: getSceneInput(),
|
||||
},
|
||||
});
|
||||
if (result.data?.sceneUpdate) {
|
||||
Toast.success({ content: "Updated scene" });
|
||||
}
|
||||
|
|
|
@ -535,3 +535,13 @@ input[type="range"].blue-slider {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.organized-button {
|
||||
&.not-organized {
|
||||
color: rgba(191, 204, 214, 0.5);
|
||||
}
|
||||
|
||||
&.organized {
|
||||
color: #664c3f;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -323,11 +323,8 @@ const sceneMutationImpactedQueries = [
|
|||
GQL.AllTagsDocument,
|
||||
];
|
||||
|
||||
export const useSceneUpdate = (input: GQL.SceneUpdateInput) =>
|
||||
export const useSceneUpdate = () =>
|
||||
GQL.useSceneUpdateMutation({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
update: deleteCache(sceneMutationImpactedQueries),
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"markers": "Markers",
|
||||
"movies": "Movies",
|
||||
"new": "New",
|
||||
"organized": "Organised",
|
||||
"performers": "Performers",
|
||||
"scenes": "Scenes",
|
||||
"studios": "Studios",
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"markers": "Markers",
|
||||
"movies": "Movies",
|
||||
"new": "New",
|
||||
"organized": "Organized",
|
||||
"performers": "Performers",
|
||||
"scenes": "Scenes",
|
||||
"studios": "Studios",
|
||||
|
|
|
@ -8,6 +8,7 @@ export type CriterionType =
|
|||
| "none"
|
||||
| "path"
|
||||
| "rating"
|
||||
| "organized"
|
||||
| "o_counter"
|
||||
| "resolution"
|
||||
| "average_resolution"
|
||||
|
@ -56,6 +57,8 @@ export abstract class Criterion {
|
|||
return "Path";
|
||||
case "rating":
|
||||
return "Rating";
|
||||
case "organized":
|
||||
return "Organized";
|
||||
case "o_counter":
|
||||
return "O-Counter";
|
||||
case "resolution":
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import { Criterion, CriterionType, ICriterionOption } from "./criterion";
|
||||
|
||||
export class OrganizedCriterion extends Criterion {
|
||||
public type: CriterionType = "organized";
|
||||
public parameterName: string = "organized";
|
||||
public modifier = CriterionModifier.Equals;
|
||||
public modifierOptions = [];
|
||||
public options: string[] = [true.toString(), false.toString()];
|
||||
public value: string = "";
|
||||
}
|
||||
|
||||
export class OrganizedCriterionOption implements ICriterionOption {
|
||||
public label: string = Criterion.getLabel("organized");
|
||||
public value: CriterionType = "organized";
|
||||
}
|
|
@ -8,6 +8,7 @@ import {
|
|||
DurationCriterion,
|
||||
MandatoryStringCriterion,
|
||||
} from "./criterion";
|
||||
import { OrganizedCriterion } from "./organized";
|
||||
import { FavoriteCriterion } from "./favorite";
|
||||
import { HasMarkersCriterion } from "./has-markers";
|
||||
import {
|
||||
|
@ -37,6 +38,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||
return new MandatoryStringCriterion(type, type);
|
||||
case "rating":
|
||||
return new RatingCriterion();
|
||||
case "organized":
|
||||
return new OrganizedCriterion();
|
||||
case "o_counter":
|
||||
case "scene_count":
|
||||
case "marker_count":
|
||||
|
|
|
@ -27,6 +27,10 @@ import {
|
|||
FavoriteCriterion,
|
||||
FavoriteCriterionOption,
|
||||
} from "./criteria/favorite";
|
||||
import {
|
||||
OrganizedCriterion,
|
||||
OrganizedCriterionOption,
|
||||
} from "./criteria/organized";
|
||||
import {
|
||||
HasMarkersCriterion,
|
||||
HasMarkersCriterionOption,
|
||||
|
@ -115,6 +119,7 @@ export class ListFilterModel {
|
|||
"title",
|
||||
"path",
|
||||
"rating",
|
||||
"organized",
|
||||
"o_counter",
|
||||
"date",
|
||||
"filesize",
|
||||
|
@ -134,6 +139,7 @@ export class ListFilterModel {
|
|||
new NoneCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("path"),
|
||||
new RatingCriterionOption(),
|
||||
new OrganizedCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("o_counter"),
|
||||
new ResolutionCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("duration"),
|
||||
|
@ -161,6 +167,7 @@ export class ListFilterModel {
|
|||
new NoneCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("path"),
|
||||
new RatingCriterionOption(),
|
||||
new OrganizedCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("o_counter"),
|
||||
new ResolutionCriterionOption(),
|
||||
new ImageIsMissingCriterionOption(),
|
||||
|
@ -234,6 +241,7 @@ export class ListFilterModel {
|
|||
new NoneCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("path"),
|
||||
new RatingCriterionOption(),
|
||||
new OrganizedCriterionOption(),
|
||||
new AverageResolutionCriterionOption(),
|
||||
new GalleryIsMissingCriterionOption(),
|
||||
new TagsCriterionOption(),
|
||||
|
@ -435,6 +443,10 @@ export class ListFilterModel {
|
|||
};
|
||||
break;
|
||||
}
|
||||
case "organized": {
|
||||
result.organized = (criterion as OrganizedCriterion).value === "true";
|
||||
break;
|
||||
}
|
||||
case "o_counter": {
|
||||
const oCounterCrit = criterion as NumberCriterion;
|
||||
result.o_counter = {
|
||||
|
@ -669,6 +681,10 @@ export class ListFilterModel {
|
|||
};
|
||||
break;
|
||||
}
|
||||
case "organized": {
|
||||
result.organized = (criterion as OrganizedCriterion).value === "true";
|
||||
break;
|
||||
}
|
||||
case "o_counter": {
|
||||
const oCounterCrit = criterion as NumberCriterion;
|
||||
result.o_counter = {
|
||||
|
@ -800,6 +816,10 @@ export class ListFilterModel {
|
|||
};
|
||||
break;
|
||||
}
|
||||
case "organized": {
|
||||
result.organized = (criterion as OrganizedCriterion).value === "true";
|
||||
break;
|
||||
}
|
||||
case "average_resolution": {
|
||||
switch ((criterion as AverageResolutionCriterion).value) {
|
||||
case "240p":
|
||||
|
|
Loading…
Reference in New Issue