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:
WithoutPants
2020-12-18 08:06:49 +11:00
committed by GitHub
parent 99bd7bc750
commit aadbcaeec2
58 changed files with 543 additions and 81 deletions

View File

@@ -7,6 +7,7 @@ fragment GallerySlimData on Gallery {
url url
details details
rating rating
organized
image_count image_count
cover { cover {
...SlimImageData ...SlimImageData

View File

@@ -7,6 +7,7 @@ fragment GalleryData on Gallery {
url url
details details
rating rating
organized
images { images {
...SlimImageData ...SlimImageData
} }

View File

@@ -3,6 +3,7 @@ fragment SlimImageData on Image {
checksum checksum
title title
rating rating
organized
o_counter o_counter
path path

View File

@@ -3,6 +3,7 @@ fragment ImageData on Image {
checksum checksum
title title
rating rating
organized
o_counter o_counter
path path

View File

@@ -8,6 +8,7 @@ fragment SlimSceneData on Scene {
date date
rating rating
o_counter o_counter
organized
path path
file { file {

View File

@@ -8,6 +8,7 @@ fragment SceneData on Scene {
date date
rating rating
o_counter o_counter
organized
path path
file { file {

View File

@@ -4,6 +4,7 @@ mutation GalleryCreate(
$url: String, $url: String,
$date: String, $date: String,
$rating: Int, $rating: Int,
$organized: Boolean,
$scene_id: ID, $scene_id: ID,
$studio_id: ID, $studio_id: ID,
$performer_ids: [ID!] = [], $performer_ids: [ID!] = [],

View File

@@ -70,6 +70,8 @@ input SceneFilterType {
path: StringCriterionInput path: StringCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by o-counter""" """Filter by o-counter"""
o_counter: IntCriterionInput o_counter: IntCriterionInput
"""Filter by resolution""" """Filter by resolution"""
@@ -117,6 +119,8 @@ input GalleryFilterType {
is_zip: Boolean is_zip: Boolean
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by average image resolution""" """Filter by average image resolution"""
average_resolution: ResolutionEnum average_resolution: ResolutionEnum
"""Filter to only include scenes with this studio""" """Filter to only include scenes with this studio"""
@@ -145,6 +149,8 @@ input ImageFilterType {
path: StringCriterionInput path: StringCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by o-counter""" """Filter by o-counter"""
o_counter: IntCriterionInput o_counter: IntCriterionInput
"""Filter by resolution""" """Filter by resolution"""

View File

@@ -8,6 +8,7 @@ type Gallery {
date: String date: String
details: String details: String
rating: Int rating: Int
organized: Boolean!
scene: Scene scene: Scene
studio: Studio studio: Studio
image_count: Int! image_count: Int!
@@ -31,6 +32,7 @@ input GalleryCreateInput {
date: String date: String
details: String details: String
rating: Int rating: Int
organized: Boolean
scene_id: ID scene_id: ID
studio_id: ID studio_id: ID
tag_ids: [ID!] tag_ids: [ID!]
@@ -45,6 +47,7 @@ input GalleryUpdateInput {
date: String date: String
details: String details: String
rating: Int rating: Int
organized: Boolean
scene_id: ID scene_id: ID
studio_id: ID studio_id: ID
tag_ids: [ID!] tag_ids: [ID!]
@@ -58,6 +61,7 @@ input BulkGalleryUpdateInput {
date: String date: String
details: String details: String
rating: Int rating: Int
organized: Boolean
scene_id: ID scene_id: ID
studio_id: ID studio_id: ID
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds

View File

@@ -4,6 +4,7 @@ type Image {
title: String title: String
rating: Int rating: Int
o_counter: Int o_counter: Int
organized: Boolean!
path: String! path: String!
file: ImageFileType! # Resolver file: ImageFileType! # Resolver
@@ -31,6 +32,7 @@ input ImageUpdateInput {
id: ID! id: ID!
title: String title: String
rating: Int rating: Int
organized: Boolean
studio_id: ID studio_id: ID
performer_ids: [ID!] performer_ids: [ID!]
@@ -43,6 +45,7 @@ input BulkImageUpdateInput {
ids: [ID!] ids: [ID!]
title: String title: String
rating: Int rating: Int
organized: Boolean
studio_id: ID studio_id: ID
performer_ids: BulkUpdateIds performer_ids: BulkUpdateIds

View File

@@ -32,6 +32,7 @@ type Scene {
url: String url: String
date: String date: String
rating: Int rating: Int
organized: Boolean!
o_counter: Int o_counter: Int
path: String! path: String!
@@ -60,6 +61,7 @@ input SceneUpdateInput {
url: String url: String
date: String date: String
rating: Int rating: Int
organized: Boolean
studio_id: ID studio_id: ID
gallery_id: ID gallery_id: ID
performer_ids: [ID!] performer_ids: [ID!]
@@ -89,6 +91,7 @@ input BulkSceneUpdateInput {
url: String url: String
date: String date: String
rating: Int rating: Int
organized: Boolean
studio_id: ID studio_id: ID
gallery_id: ID gallery_id: ID
performer_ids: BulkUpdateIds performer_ids: BulkUpdateIds

View File

@@ -205,6 +205,7 @@ func (r *mutationResolver) galleryUpdate(input models.GalleryUpdateInput, transl
updatedGallery.Date = translator.sqliteDate(input.Date, "date") updatedGallery.Date = translator.sqliteDate(input.Date, "date")
updatedGallery.Rating = translator.nullInt64(input.Rating, "rating") updatedGallery.Rating = translator.nullInt64(input.Rating, "rating")
updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedGallery.Organized = input.Organized
// gallery scene is set from the scene only // 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.Rating = translator.nullInt64(input.Rating, "rating")
updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedGallery.SceneID = translator.nullInt64FromString(input.SceneID, "scene_id") updatedGallery.SceneID = translator.nullInt64FromString(input.SceneID, "scene_id")
updatedGallery.Organized = input.Organized
ret := []*models.Gallery{} ret := []*models.Gallery{}

View File

@@ -77,6 +77,7 @@ func (r *mutationResolver) imageUpdate(input models.ImageUpdateInput, translator
updatedImage.Title = translator.nullString(input.Title, "title") updatedImage.Title = translator.nullString(input.Title, "title")
updatedImage.Rating = translator.nullInt64(input.Rating, "rating") updatedImage.Rating = translator.nullInt64(input.Rating, "rating")
updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedImage.Organized = input.Organized
qb := models.NewImageQueryBuilder() qb := models.NewImageQueryBuilder()
jqb := models.NewJoinsQueryBuilder() 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.Title = translator.nullString(input.Title, "title")
updatedImage.Rating = translator.nullInt64(input.Rating, "rating") updatedImage.Rating = translator.nullInt64(input.Rating, "rating")
updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedImage.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedImage.Organized = input.Organized
ret := []*models.Image{} ret := []*models.Image{}

View File

@@ -85,6 +85,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
updatedScene.Date = translator.sqliteDate(input.Date, "date") updatedScene.Date = translator.sqliteDate(input.Date, "date")
updatedScene.Rating = translator.nullInt64(input.Rating, "rating") updatedScene.Rating = translator.nullInt64(input.Rating, "rating")
updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedScene.Organized = input.Organized
if input.CoverImage != nil && *input.CoverImage != "" { if input.CoverImage != nil && *input.CoverImage != "" {
var err error 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.Date = translator.sqliteDate(input.Date, "date")
updatedScene.Rating = translator.nullInt64(input.Rating, "rating") updatedScene.Rating = translator.nullInt64(input.Rating, "rating")
updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedScene.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedScene.Organized = input.Organized
ret := []*models.Scene{} ret := []*models.Scene{}

View File

@@ -20,7 +20,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var dbPath string var dbPath string
var appSchemaVersion uint = 15 var appSchemaVersion uint = 16
var databaseSchemaVersion uint var databaseSchemaVersion uint
const sqlite3Driver = "sqlite3ex" const sqlite3Driver = "sqlite3ex"

View File

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

View File

@@ -40,6 +40,8 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
newGalleryJSON.Rating = int(gallery.Rating.Int64) newGalleryJSON.Rating = int(gallery.Rating.Int64)
} }
newGalleryJSON.Organized = gallery.Organized
if gallery.Details.Valid { if gallery.Details.Valid {
newGalleryJSON.Details = gallery.Details.String newGalleryJSON.Details = gallery.Details.String
} }

View File

@@ -25,14 +25,15 @@ const (
) )
const ( const (
path = "path" path = "path"
zip = true zip = true
url = "url" url = "url"
checksum = "checksum" checksum = "checksum"
title = "title" title = "title"
date = "2001-01-01" date = "2001-01-01"
rating = 5 rating = 5
details = "details" organized = true
details = "details"
) )
const ( const (
@@ -58,9 +59,10 @@ func createFullGallery(id int) models.Gallery {
String: date, String: date,
Valid: true, Valid: true,
}, },
Details: modelstest.NullString(details), Details: modelstest.NullString(details),
Rating: modelstest.NullInt64(rating), Rating: modelstest.NullInt64(rating),
URL: modelstest.NullString(url), Organized: organized,
URL: modelstest.NullString(url),
CreatedAt: models.SQLiteTimestamp{ CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime, Timestamp: createTime,
}, },
@@ -84,14 +86,15 @@ func createEmptyGallery(id int) models.Gallery {
func createFullJSONGallery() *jsonschema.Gallery { func createFullJSONGallery() *jsonschema.Gallery {
return &jsonschema.Gallery{ return &jsonschema.Gallery{
Title: title, Title: title,
Path: path, Path: path,
Zip: zip, Zip: zip,
Checksum: checksum, Checksum: checksum,
Date: date, Date: date,
Details: details, Details: details,
Rating: rating, Rating: rating,
URL: url, Organized: organized,
URL: url,
CreatedAt: models.JSONTime{ CreatedAt: models.JSONTime{
Time: createTime, Time: createTime,
}, },

View File

@@ -68,6 +68,7 @@ func (i *Importer) galleryJSONToGallery(galleryJSON jsonschema.Gallery) models.G
newGallery.Rating = sql.NullInt64{Int64: int64(galleryJSON.Rating), Valid: true} newGallery.Rating = sql.NullInt64{Int64: int64(galleryJSON.Rating), Valid: true}
} }
newGallery.Organized = galleryJSON.Organized
newGallery.CreatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.CreatedAt.GetTime()} newGallery.CreatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.CreatedAt.GetTime()}
newGallery.UpdatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.UpdatedAt.GetTime()} newGallery.UpdatedAt = models.SQLiteTimestamp{Timestamp: galleryJSON.UpdatedAt.GetTime()}

View File

@@ -56,13 +56,14 @@ func TestImporterName(t *testing.T) {
func TestImporterPreImport(t *testing.T) { func TestImporterPreImport(t *testing.T) {
i := Importer{ i := Importer{
Input: jsonschema.Gallery{ Input: jsonschema.Gallery{
Path: path, Path: path,
Checksum: checksum, Checksum: checksum,
Title: title, Title: title,
Date: date, Date: date,
Details: details, Details: details,
Rating: rating, Rating: rating,
URL: url, Organized: organized,
URL: url,
CreatedAt: models.JSONTime{ CreatedAt: models.JSONTime{
Time: createdAt, Time: createdAt,
}, },
@@ -83,9 +84,10 @@ func TestImporterPreImport(t *testing.T) {
String: date, String: date,
Valid: true, Valid: true,
}, },
Details: modelstest.NullString(details), Details: modelstest.NullString(details),
Rating: modelstest.NullInt64(rating), Rating: modelstest.NullInt64(rating),
URL: modelstest.NullString(url), Organized: organized,
URL: modelstest.NullString(url),
CreatedAt: models.SQLiteTimestamp{ CreatedAt: models.SQLiteTimestamp{
Timestamp: createdAt, Timestamp: createdAt,
}, },

View File

@@ -23,6 +23,7 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image {
newImageJSON.Rating = int(image.Rating.Int64) newImageJSON.Rating = int(image.Rating.Int64)
} }
newImageJSON.Organized = image.Organized
newImageJSON.OCounter = image.OCounter newImageJSON.OCounter = image.OCounter
newImageJSON.File = getImageFileJSON(image) newImageJSON.File = getImageFileJSON(image)

View File

@@ -39,13 +39,14 @@ const (
) )
const ( const (
checksum = "checksum" checksum = "checksum"
title = "title" title = "title"
rating = 5 rating = 5
ocounter = 2 organized = true
size = 123 ocounter = 2
width = 100 size = 123
height = 100 width = 100
height = 100
) )
const ( 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 { func createFullImage(id int) models.Image {
return models.Image{ return models.Image{
ID: id, ID: id,
Title: modelstest.NullString(title), Title: modelstest.NullString(title),
Checksum: checksum, Checksum: checksum,
Height: modelstest.NullInt64(height), Height: modelstest.NullInt64(height),
OCounter: ocounter, OCounter: ocounter,
Rating: modelstest.NullInt64(rating), Rating: modelstest.NullInt64(rating),
Size: modelstest.NullInt64(int64(size)), Organized: organized,
Width: modelstest.NullInt64(width), Size: modelstest.NullInt64(int64(size)),
Width: modelstest.NullInt64(width),
CreatedAt: models.SQLiteTimestamp{ CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime, Timestamp: createTime,
}, },
@@ -94,10 +96,11 @@ func createEmptyImage(id int) models.Image {
func createFullJSONImage() *jsonschema.Image { func createFullJSONImage() *jsonschema.Image {
return &jsonschema.Image{ return &jsonschema.Image{
Title: title, Title: title,
Checksum: checksum, Checksum: checksum,
OCounter: ocounter, OCounter: ocounter,
Rating: rating, Rating: rating,
Organized: organized,
File: &jsonschema.ImageFile{ File: &jsonschema.ImageFile{
Height: height, Height: height,
Size: size, Size: size,

View File

@@ -63,6 +63,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
newImage.Rating = sql.NullInt64{Int64: int64(imageJSON.Rating), Valid: true} newImage.Rating = sql.NullInt64{Int64: int64(imageJSON.Rating), Valid: true}
} }
newImage.Organized = imageJSON.Organized
newImage.OCounter = imageJSON.OCounter newImage.OCounter = imageJSON.OCounter
newImage.CreatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.CreatedAt.GetTime()} newImage.CreatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.CreatedAt.GetTime()}
newImage.UpdatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.UpdatedAt.GetTime()} newImage.UpdatedAt = models.SQLiteTimestamp{Timestamp: imageJSON.UpdatedAt.GetTime()}

View File

@@ -17,6 +17,7 @@ type Gallery struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
Performers []string `json:"performers,omitempty"` Performers []string `json:"performers,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`

View File

@@ -20,6 +20,7 @@ type Image struct {
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"` OCounter int `json:"o_counter,omitempty"`
Galleries []string `json:"galleries,omitempty"` Galleries []string `json:"galleries,omitempty"`
Performers []string `json:"performers,omitempty"` Performers []string `json:"performers,omitempty"`

View File

@@ -43,6 +43,7 @@ type Scene struct {
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"` OCounter int `json:"o_counter,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
Gallery string `json:"gallery,omitempty"` Gallery string `json:"gallery,omitempty"`

View File

@@ -37,7 +37,8 @@ func (t *AutoTagPerformerTask) autoTagPerformer() {
regex := getQueryRegex(t.performer.Name.String) regex := getQueryRegex(t.performer.Name.String)
scenes, err := qb.QueryAllByPathRegex(regex) const ignoreOrganized = true
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
if err != nil { if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error()) 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) regex := getQueryRegex(t.studio.Name.String)
scenes, err := qb.QueryAllByPathRegex(regex) const ignoreOrganized = true
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
if err != nil { if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error()) 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) regex := getQueryRegex(t.tag.Name)
scenes, err := qb.QueryAllByPathRegex(regex) const ignoreOrganized = true
scenes, err := qb.QueryAllByPathRegex(regex, ignoreOrganized)
if err != nil { if err != nil {
logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error()) logger.Infof("Error querying scenes with regex '%s': %s", regex, err.Error())

View File

@@ -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 // create scene with existing studio io
studioScene := makeScene(existingStudioSceneName, true) studioScene := makeScene(existingStudioSceneName, true)
studioScene.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)} studioScene.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)}

View File

@@ -14,6 +14,7 @@ type Gallery struct {
Date SQLiteDate `db:"date" json:"date"` Date SQLiteDate `db:"date" json:"date"`
Details sql.NullString `db:"details" json:"details"` Details sql.NullString `db:"details" json:"details"`
Rating sql.NullInt64 `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
Organized bool `db:"organized" json:"organized"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
SceneID sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"` SceneID sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
FileModTime NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"` FileModTime NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`
@@ -32,6 +33,7 @@ type GalleryPartial struct {
Date *SQLiteDate `db:"date" json:"date"` Date *SQLiteDate `db:"date" json:"date"`
Details *sql.NullString `db:"details" json:"details"` Details *sql.NullString `db:"details" json:"details"`
Rating *sql.NullInt64 `db:"rating" json:"rating"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
Organized *bool `db:"organized" json:"organized"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
SceneID *sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"` SceneID *sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
FileModTime *NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"` FileModTime *NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`

View File

@@ -11,6 +11,7 @@ type Image struct {
Path string `db:"path" json:"path"` Path string `db:"path" json:"path"`
Title sql.NullString `db:"title" json:"title"` Title sql.NullString `db:"title" json:"title"`
Rating sql.NullInt64 `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
Organized bool `db:"organized" json:"organized"`
OCounter int `db:"o_counter" json:"o_counter"` OCounter int `db:"o_counter" json:"o_counter"`
Size sql.NullInt64 `db:"size" json:"size"` Size sql.NullInt64 `db:"size" json:"size"`
Width sql.NullInt64 `db:"width" json:"width"` Width sql.NullInt64 `db:"width" json:"width"`
@@ -29,6 +30,7 @@ type ImagePartial struct {
Path *string `db:"path" json:"path"` Path *string `db:"path" json:"path"`
Title *sql.NullString `db:"title" json:"title"` Title *sql.NullString `db:"title" json:"title"`
Rating *sql.NullInt64 `db:"rating" json:"rating"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
Organized *bool `db:"organized" json:"organized"`
Size *sql.NullInt64 `db:"size" json:"size"` Size *sql.NullInt64 `db:"size" json:"size"`
Width *sql.NullInt64 `db:"width" json:"width"` Width *sql.NullInt64 `db:"width" json:"width"`
Height *sql.NullInt64 `db:"height" json:"height"` Height *sql.NullInt64 `db:"height" json:"height"`

View File

@@ -16,6 +16,7 @@ type Scene struct {
URL sql.NullString `db:"url" json:"url"` URL sql.NullString `db:"url" json:"url"`
Date SQLiteDate `db:"date" json:"date"` Date SQLiteDate `db:"date" json:"date"`
Rating sql.NullInt64 `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
Organized bool `db:"organized" json:"organized"`
OCounter int `db:"o_counter" json:"o_counter"` OCounter int `db:"o_counter" json:"o_counter"`
Size sql.NullString `db:"size" json:"size"` Size sql.NullString `db:"size" json:"size"`
Duration sql.NullFloat64 `db:"duration" json:"duration"` Duration sql.NullFloat64 `db:"duration" json:"duration"`
@@ -44,6 +45,7 @@ type ScenePartial struct {
URL *sql.NullString `db:"url" json:"url"` URL *sql.NullString `db:"url" json:"url"`
Date *SQLiteDate `db:"date" json:"date"` Date *SQLiteDate `db:"date" json:"date"`
Rating *sql.NullInt64 `db:"rating" json:"rating"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
Organized *bool `db:"organized" json:"organized"`
Size *sql.NullString `db:"size" json:"size"` Size *sql.NullString `db:"size" json:"size"`
Duration *sql.NullFloat64 `db:"duration" json:"duration"` Duration *sql.NullFloat64 `db:"duration" json:"duration"`
VideoCodec *sql.NullString `db:"video_codec" json:"video_codec"` VideoCodec *sql.NullString `db:"video_codec" json:"video_codec"`

View File

@@ -15,3 +15,10 @@ func NullInt64(v int64) sql.NullInt64 {
Valid: true, Valid: true,
} }
} }
func NullBool(v bool) sql.NullBool {
return sql.NullBool{
Bool: v,
Valid: true,
}
}

View File

@@ -21,8 +21,8 @@ func NewGalleryQueryBuilder() GalleryQueryBuilder {
func (qb *GalleryQueryBuilder) Create(newGallery Gallery, tx *sqlx.Tx) (*Gallery, error) { func (qb *GalleryQueryBuilder) Create(newGallery Gallery, tx *sqlx.Tx) (*Gallery, error) {
ensureTx(tx) ensureTx(tx)
result, err := tx.NamedExec( 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) `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, :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, newGallery,
) )
@@ -232,6 +232,16 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating")
qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) 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 != "" { if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter { switch *isMissingFilter {
case "scene": case "scene":

View File

@@ -61,9 +61,9 @@ func NewImageQueryBuilder() ImageQueryBuilder {
func (qb *ImageQueryBuilder) Create(newImage Image, tx *sqlx.Tx) (*Image, error) { func (qb *ImageQueryBuilder) Create(newImage Image, tx *sqlx.Tx) (*Image, error) {
ensureTx(tx) ensureTx(tx)
result, err := tx.NamedExec( 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) 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) :width, :height, :studio_id, :file_mod_time, :created_at, :updated_at)
`, `,
newImage, 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 resolutionFilter := imageFilter.Resolution; resolutionFilter != nil {
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
switch resolution { switch resolution {

View File

@@ -59,9 +59,9 @@ func NewSceneQueryBuilder() SceneQueryBuilder {
func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error) { func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error) {
ensureTx(tx) ensureTx(tx)
result, err := tx.NamedExec( 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) 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) :audio_codec, :format, :width, :height, :framerate, :bitrate, :studio_id, :file_mod_time, :created_at, :updated_at)
`, `,
newScene, newScene,
@@ -325,6 +325,16 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating") query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating")
query.handleIntCriterionInput(sceneFilter.OCounter, "scenes.o_counter") 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 { if durationFilter := sceneFilter.Duration; durationFilter != nil {
clause, thisArgs := getDurationWhereClause(*durationFilter) clause, thisArgs := getDurationWhereClause(*durationFilter)
query.addWhere(clause) query.addWhere(clause)
@@ -473,10 +483,14 @@ func getDurationWhereClause(durationFilter IntCriterionInput) (string, []interfa
return clause, args return clause, args
} }
func (qb *SceneQueryBuilder) QueryAllByPathRegex(regex string) ([]*Scene, error) { func (qb *SceneQueryBuilder) QueryAllByPathRegex(regex string, ignoreOrganized bool) ([]*Scene, error) {
var args []interface{} var args []interface{}
body := selectDistinctIDs("scenes") + " WHERE scenes.path regexp ?" body := selectDistinctIDs("scenes") + " WHERE scenes.path regexp ?"
if ignoreOrganized {
body += " AND scenes.organized = 0"
}
args = append(args, "(?i)"+regex) args = append(args, "(?i)"+regex)
idsResult, err := runIdsQuery(body, args) idsResult, err := runIdsQuery(body, args)

View File

@@ -43,6 +43,7 @@ func ToBasicJSON(reader models.SceneReader, scene *models.Scene) (*jsonschema.Sc
newSceneJSON.Rating = int(scene.Rating.Int64) newSceneJSON.Rating = int(scene.Rating.Int64)
} }
newSceneJSON.Organized = scene.Organized
newSceneJSON.OCounter = scene.OCounter newSceneJSON.OCounter = scene.OCounter
if scene.Details.Valid { if scene.Details.Valid {

View File

@@ -47,6 +47,7 @@ const (
date = "2001-01-01" date = "2001-01-01"
rating = 5 rating = 5
ocounter = 2 ocounter = 2
organized = true
details = "details" details = "details"
size = "size" size = "size"
duration = 1.23 duration = 1.23
@@ -113,6 +114,7 @@ func createFullScene(id int) models.Scene {
OCounter: ocounter, OCounter: ocounter,
OSHash: modelstest.NullString(oshash), OSHash: modelstest.NullString(oshash),
Rating: modelstest.NullInt64(rating), Rating: modelstest.NullInt64(rating),
Organized: organized,
Size: modelstest.NullString(size), Size: modelstest.NullString(size),
VideoCodec: modelstest.NullString(videoCodec), VideoCodec: modelstest.NullString(videoCodec),
Width: modelstest.NullInt64(width), Width: modelstest.NullInt64(width),
@@ -140,14 +142,15 @@ func createEmptyScene(id int) models.Scene {
func createFullJSONScene(image string) *jsonschema.Scene { func createFullJSONScene(image string) *jsonschema.Scene {
return &jsonschema.Scene{ return &jsonschema.Scene{
Title: title, Title: title,
Checksum: checksum, Checksum: checksum,
Date: date, Date: date,
Details: details, Details: details,
OCounter: ocounter, OCounter: ocounter,
OSHash: oshash, OSHash: oshash,
Rating: rating, Rating: rating,
URL: url, Organized: organized,
URL: url,
File: &jsonschema.SceneFile{ File: &jsonschema.SceneFile{
AudioCodec: audioCodec, AudioCodec: audioCodec,
Bitrate: bitrate, Bitrate: bitrate,

View File

@@ -90,6 +90,7 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene {
newScene.Rating = sql.NullInt64{Int64: int64(sceneJSON.Rating), Valid: true} newScene.Rating = sql.NullInt64{Int64: int64(sceneJSON.Rating), Valid: true}
} }
newScene.Organized = sceneJSON.Organized
newScene.OCounter = sceneJSON.OCounter newScene.OCounter = sceneJSON.OCounter
newScene.CreatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.CreatedAt.GetTime()} newScene.CreatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.CreatedAt.GetTime()}
newScene.UpdatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.UpdatedAt.GetTime()} newScene.UpdatedAt = models.SQLiteTimestamp{Timestamp: sceneJSON.UpdatedAt.GetTime()}

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Add organized flag for scenes, galleries and images.
* Allow configuration of visible navbar items. * Allow configuration of visible navbar items.
### 🎨 Improvements ### 🎨 Improvements

View File

@@ -28,12 +28,15 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
GQL.BulkUpdateIdMode.Add GQL.BulkUpdateIdMode.Add
); );
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateGalleries] = useBulkGalleryUpdate(); const [updateGalleries] = useBulkGalleryUpdate();
// Network state // Network state
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds( function makeBulkUpdateIds(
ids: string[], ids: string[],
mode: GQL.BulkUpdateIdMode mode: GQL.BulkUpdateIdMode
@@ -119,6 +122,10 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
} }
if (organized !== undefined) {
galleryInput.organized = organized;
}
return galleryInput; return galleryInput;
} }
@@ -223,6 +230,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
let updateStudioID: string | undefined; let updateStudioID: string | undefined;
let updatePerformerIds: string[] = []; let updatePerformerIds: string[] = [];
let updateTagIds: string[] = []; let updateTagIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true; let first = true;
state.forEach((gallery: GQL.GallerySlimDataFragment) => { state.forEach((gallery: GQL.GallerySlimDataFragment) => {
@@ -238,6 +246,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
updateStudioID = GalleriestudioID; updateStudioID = GalleriestudioID;
updatePerformerIds = galleryPerformerIDs; updatePerformerIds = galleryPerformerIDs;
updateTagIds = galleryTagIDs; updateTagIds = galleryTagIDs;
updateOrganized = gallery.organized;
first = false; first = false;
} else { } else {
if (galleryRating !== updateRating) { if (galleryRating !== updateRating) {
@@ -252,6 +261,9 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
if (!_.isEqual(galleryTagIDs, updateTagIds)) { if (!_.isEqual(galleryTagIDs, updateTagIds)) {
updateTagIds = []; updateTagIds = [];
} }
if (gallery.organized !== updateOrganized) {
updateOrganized = undefined;
}
} }
}); });
@@ -264,8 +276,16 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
if (tagMode === GQL.BulkUpdateIdMode.Set) { if (tagMode === GQL.BulkUpdateIdMode.Set) {
setTagIds(updateTagIds); setTagIds(updateTagIds);
} }
setOrganized(updateOrganized);
}, [props.selected, performerMode, tagMode]); }, [props.selected, performerMode, tagMode]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect( function renderMultiSelect(
type: "performers" | "tags", type: "performers" | "tags",
ids: string[] | undefined 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() { function render() {
return ( return (
<Modal <Modal
@@ -359,10 +389,20 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
label="Organized"
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
/>
</Form.Group>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -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() { function maybeRenderPopoverButtonGroup() {
if ( if (
props.gallery.scene || props.gallery.scene ||
props.gallery.performers.length > 0 || props.gallery.performers.length > 0 ||
props.gallery.tags.length > 0 props.gallery.tags.length > 0 ||
props.gallery.organized
) { ) {
return ( return (
<> <>
@@ -117,6 +130,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()} {maybeRenderPerformerPopoverButton()}
{maybeRenderScenePopoverButton()} {maybeRenderScenePopoverButton()}
{maybeRenderOrganized()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View File

@@ -1,10 +1,12 @@
import { Tab, Nav, Dropdown } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; 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 { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import * as Mousetrap from "mousetrap"; import * as Mousetrap from "mousetrap";
import { useToast } from "src/hooks";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { GalleryEditPanel } from "./GalleryEditPanel"; import { GalleryEditPanel } from "./GalleryEditPanel";
import { GalleryDetailPanel } from "./GalleryDetailPanel"; import { GalleryDetailPanel } from "./GalleryDetailPanel";
import { DeleteGalleriesDialog } from "../DeleteGalleriesDialog"; import { DeleteGalleriesDialog } from "../DeleteGalleriesDialog";
@@ -20,6 +22,7 @@ interface IGalleryParams {
export const Gallery: React.FC = () => { export const Gallery: React.FC = () => {
const { tab = "images", id = "new" } = useParams<IGalleryParams>(); const { tab = "images", id = "new" } = useParams<IGalleryParams>();
const history = useHistory(); const history = useHistory();
const Toast = useToast();
const isNew = id === "new"; const isNew = id === "new";
const { data, error, loading } = useFindGallery(id); 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); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function onDeleteDialogClosed(deleted: boolean) { function onDeleteDialogClosed(deleted: boolean) {
@@ -103,7 +128,14 @@ export const Gallery: React.FC = () => {
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-edit-panel">Edit</Nav.Link> <Nav.Link eventKey="gallery-edit-panel">Edit</Nav.Link>
</Nav.Item> </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> </Nav>
</div> </div>

View File

@@ -28,12 +28,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
GQL.BulkUpdateIdMode.Add GQL.BulkUpdateIdMode.Add
); );
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateImages] = useBulkImageUpdate(); const [updateImages] = useBulkImageUpdate();
// Network state // Network state
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds( function makeBulkUpdateIds(
ids: string[], ids: string[],
mode: GQL.BulkUpdateIdMode mode: GQL.BulkUpdateIdMode
@@ -119,6 +122,10 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
} }
if (organized !== undefined) {
imageInput.organized = organized;
}
return imageInput; return imageInput;
} }
@@ -221,6 +228,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
let updateStudioID: string | undefined; let updateStudioID: string | undefined;
let updatePerformerIds: string[] = []; let updatePerformerIds: string[] = [];
let updateTagIds: string[] = []; let updateTagIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true; let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => { state.forEach((image: GQL.SlimImageDataFragment) => {
@@ -236,6 +244,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
updateStudioID = imageStudioID; updateStudioID = imageStudioID;
updatePerformerIds = imagePerformerIDs; updatePerformerIds = imagePerformerIDs;
updateTagIds = imageTagIDs; updateTagIds = imageTagIDs;
updateOrganized = image.organized;
first = false; first = false;
} else { } else {
if (imageRating !== updateRating) { if (imageRating !== updateRating) {
@@ -250,6 +259,9 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
if (!_.isEqual(imageTagIDs, updateTagIds)) { if (!_.isEqual(imageTagIDs, updateTagIds)) {
updateTagIds = []; updateTagIds = [];
} }
if (image.organized !== updateOrganized) {
updateOrganized = undefined;
}
} }
}); });
@@ -262,8 +274,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
if (tagMode === GQL.BulkUpdateIdMode.Set) { if (tagMode === GQL.BulkUpdateIdMode.Set) {
setTagIds(updateTagIds); setTagIds(updateTagIds);
} }
setOrganized(updateOrganized);
}, [props.selected, performerMode, tagMode]); }, [props.selected, performerMode, tagMode]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect( function renderMultiSelect(
type: "performers" | "tags", type: "performers" | "tags",
ids: string[] | undefined 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() { function render() {
return ( return (
<Modal <Modal
@@ -357,10 +386,20 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
label="Organized"
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
/>
</Form.Group>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -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() { function maybeRenderPopoverButtonGroup() {
if ( if (
props.image.tags.length > 0 || props.image.tags.length > 0 ||
props.image.performers.length > 0 || props.image.performers.length > 0 ||
props.image?.o_counter props.image.o_counter ||
props.image.organized
) { ) {
return ( return (
<> <>
@@ -106,6 +119,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()} {maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()} {maybeRenderOCounter()}
{maybeRenderOrganized()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View File

@@ -6,12 +6,14 @@ import {
useImageIncrementO, useImageIncrementO,
useImageDecrementO, useImageDecrementO,
useImageResetO, useImageResetO,
useImageUpdate,
} from "src/core/StashService"; } from "src/core/StashService";
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import * as Mousetrap from "mousetrap"; import * as Mousetrap from "mousetrap";
import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { ImageFileInfoPanel } from "./ImageFileInfoPanel"; import { ImageFileInfoPanel } from "./ImageFileInfoPanel";
import { ImageEditPanel } from "./ImageEditPanel"; import { ImageEditPanel } from "./ImageEditPanel";
import { ImageDetailPanel } from "./ImageDetailPanel"; import { ImageDetailPanel } from "./ImageDetailPanel";
@@ -33,10 +35,32 @@ export const Image: React.FC = () => {
const [decrementO] = useImageDecrementO(image?.id ?? "0"); const [decrementO] = useImageDecrementO(image?.id ?? "0");
const [resetO] = useImageResetO(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 [activeTabKey, setActiveTabKey] = useState("image-details-panel");
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); 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 () => { const onIncrementClick = async () => {
try { try {
setOLoading(true); setOLoading(true);
@@ -139,6 +163,13 @@ export const Image: React.FC = () => {
onReset={onResetClick} onReset={onResetClick}
/> />
</Nav.Item> </Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={image.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item> <Nav.Item>{renderOperations()}</Nav.Item>
</Nav> </Nav>
</div> </div>

View File

@@ -28,12 +28,15 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
GQL.BulkUpdateIdMode.Add GQL.BulkUpdateIdMode.Add
); );
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [organized, setOrganized] = useState<boolean | undefined>();
const [updateScenes] = useBulkSceneUpdate(getSceneInput()); const [updateScenes] = useBulkSceneUpdate(getSceneInput());
// Network state // Network state
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds( function makeBulkUpdateIds(
ids: string[], ids: string[],
mode: GQL.BulkUpdateIdMode mode: GQL.BulkUpdateIdMode
@@ -119,6 +122,10 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
} }
if (organized !== undefined) {
sceneInput.organized = organized;
}
return sceneInput; return sceneInput;
} }
@@ -217,6 +224,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
let updateStudioID: string | undefined; let updateStudioID: string | undefined;
let updatePerformerIds: string[] = []; let updatePerformerIds: string[] = [];
let updateTagIds: string[] = []; let updateTagIds: string[] = [];
let updateOrganized: boolean | undefined;
let first = true; let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
@@ -233,6 +241,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
updatePerformerIds = scenePerformerIDs; updatePerformerIds = scenePerformerIDs;
updateTagIds = sceneTagIDs; updateTagIds = sceneTagIDs;
first = false; first = false;
updateOrganized = scene.organized;
} else { } else {
if (sceneRating !== updateRating) { if (sceneRating !== updateRating) {
updateRating = undefined; updateRating = undefined;
@@ -246,6 +255,9 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
if (!_.isEqual(sceneTagIDs, updateTagIds)) { if (!_.isEqual(sceneTagIDs, updateTagIds)) {
updateTagIds = []; updateTagIds = [];
} }
if (scene.organized !== updateOrganized) {
updateOrganized = undefined;
}
} }
}); });
@@ -258,8 +270,15 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
if (tagMode === GQL.BulkUpdateIdMode.Set) { if (tagMode === GQL.BulkUpdateIdMode.Set) {
setTagIds(updateTagIds); setTagIds(updateTagIds);
} }
setOrganized(updateOrganized);
}, [props.selected, performerMode, tagMode]); }, [props.selected, performerMode, tagMode]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = organized === undefined;
}
}, [organized, checkboxRef]);
function renderMultiSelect( function renderMultiSelect(
type: "performers" | "tags", type: "performers" | "tags",
ids: string[] | undefined 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() { function render() {
return ( return (
<Modal <Modal
@@ -353,10 +382,20 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
{renderMultiSelect("performers", performerIds)} {renderMultiSelect("performers", performerIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="performers"> <Form.Group controlId="tags">
<Form.Label>Tags</Form.Label> <Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<Form.Group controlId="organized">
<Form.Check
type="checkbox"
label="Organized"
checked={organized}
ref={checkboxRef}
onChange={() => cycleOrganized()}
/>
</Form.Group>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -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() { function maybeRenderPopoverButtonGroup() {
if ( if (
props.scene.tags.length > 0 || props.scene.tags.length > 0 ||
@@ -274,7 +286,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (
props.scene.movies.length > 0 || props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 || props.scene.scene_markers.length > 0 ||
props.scene?.o_counter || props.scene?.o_counter ||
props.scene.gallery props.scene.gallery ||
props.scene.organized
) { ) {
return ( return (
<> <>
@@ -286,6 +299,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
{maybeRenderSceneMarkerPopoverButton()} {maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()} {maybeRenderOCounter()}
{maybeRenderGallery()} {maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View File

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

View File

@@ -10,6 +10,7 @@ import {
useSceneResetO, useSceneResetO,
useSceneStreams, useSceneStreams,
useSceneGenerateScreenshot, useSceneGenerateScreenshot,
useSceneUpdate,
} from "src/core/StashService"; } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
@@ -26,6 +27,7 @@ import { SceneMoviePanel } from "./SceneMoviePanel";
import { DeleteScenesDialog } from "../DeleteScenesDialog"; import { DeleteScenesDialog } from "../DeleteScenesDialog";
import { SceneGenerateDialog } from "../SceneGenerateDialog"; import { SceneGenerateDialog } from "../SceneGenerateDialog";
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel"; import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
import { OrganizedButton } from "./OrganizedButton";
interface ISceneParams { interface ISceneParams {
id?: string; id?: string;
@@ -36,6 +38,7 @@ export const Scene: React.FC = () => {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const [updateScene] = useSceneUpdate();
const [generateScreenshot] = useSceneGenerateScreenshot(); const [generateScreenshot] = useSceneGenerateScreenshot();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -52,6 +55,8 @@ export const Scene: React.FC = () => {
const [decrementO] = useSceneDecrementO(scene?.id ?? "0"); const [decrementO] = useSceneDecrementO(scene?.id ?? "0");
const [resetO] = useSceneResetO(scene?.id ?? "0"); const [resetO] = useSceneResetO(scene?.id ?? "0");
const [organizedLoading, setOrganizedLoading] = useState(false);
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); 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 () => { const onIncrementClick = async () => {
try { try {
setOLoading(true); setOLoading(true);
@@ -246,6 +269,13 @@ export const Scene: React.FC = () => {
onReset={onResetClick} onReset={onResetClick}
/> />
</Nav.Item> </Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={scene.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item> <Nav.Item>{renderOperations()}</Nav.Item>
</ButtonGroup> </ButtonGroup>
</Nav> </Nav>

View File

@@ -70,7 +70,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
// Network state // Network state
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [updateScene] = useSceneUpdate(getSceneInput()); const [updateScene] = useSceneUpdate();
useEffect(() => { useEffect(() => {
if (props.isVisible) { if (props.isVisible) {
@@ -230,7 +230,11 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
async function onSave() { async function onSave() {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await updateScene(); const result = await updateScene({
variables: {
input: getSceneInput(),
},
});
if (result.data?.sceneUpdate) { if (result.data?.sceneUpdate) {
Toast.success({ content: "Updated scene" }); Toast.success({ content: "Updated scene" });
} }

View File

@@ -535,3 +535,13 @@ input[type="range"].blue-slider {
} }
} }
} }
.organized-button {
&.not-organized {
color: rgba(191, 204, 214, 0.5);
}
&.organized {
color: #664c3f;
}
}

View File

@@ -323,11 +323,8 @@ const sceneMutationImpactedQueries = [
GQL.AllTagsDocument, GQL.AllTagsDocument,
]; ];
export const useSceneUpdate = (input: GQL.SceneUpdateInput) => export const useSceneUpdate = () =>
GQL.useSceneUpdateMutation({ GQL.useSceneUpdateMutation({
variables: {
input,
},
update: deleteCache(sceneMutationImpactedQueries), update: deleteCache(sceneMutationImpactedQueries),
}); });

View File

@@ -6,6 +6,7 @@
"markers": "Markers", "markers": "Markers",
"movies": "Movies", "movies": "Movies",
"new": "New", "new": "New",
"organized": "Organised",
"performers": "Performers", "performers": "Performers",
"scenes": "Scenes", "scenes": "Scenes",
"studios": "Studios", "studios": "Studios",

View File

@@ -6,6 +6,7 @@
"markers": "Markers", "markers": "Markers",
"movies": "Movies", "movies": "Movies",
"new": "New", "new": "New",
"organized": "Organized",
"performers": "Performers", "performers": "Performers",
"scenes": "Scenes", "scenes": "Scenes",
"studios": "Studios", "studios": "Studios",

View File

@@ -8,6 +8,7 @@ export type CriterionType =
| "none" | "none"
| "path" | "path"
| "rating" | "rating"
| "organized"
| "o_counter" | "o_counter"
| "resolution" | "resolution"
| "average_resolution" | "average_resolution"
@@ -56,6 +57,8 @@ export abstract class Criterion {
return "Path"; return "Path";
case "rating": case "rating":
return "Rating"; return "Rating";
case "organized":
return "Organized";
case "o_counter": case "o_counter":
return "O-Counter"; return "O-Counter";
case "resolution": case "resolution":

View File

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

View File

@@ -8,6 +8,7 @@ import {
DurationCriterion, DurationCriterion,
MandatoryStringCriterion, MandatoryStringCriterion,
} from "./criterion"; } from "./criterion";
import { OrganizedCriterion } from "./organized";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import { import {
@@ -37,6 +38,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new MandatoryStringCriterion(type, type); return new MandatoryStringCriterion(type, type);
case "rating": case "rating":
return new RatingCriterion(); return new RatingCriterion();
case "organized":
return new OrganizedCriterion();
case "o_counter": case "o_counter":
case "scene_count": case "scene_count":
case "marker_count": case "marker_count":

View File

@@ -27,6 +27,10 @@ import {
FavoriteCriterion, FavoriteCriterion,
FavoriteCriterionOption, FavoriteCriterionOption,
} from "./criteria/favorite"; } from "./criteria/favorite";
import {
OrganizedCriterion,
OrganizedCriterionOption,
} from "./criteria/organized";
import { import {
HasMarkersCriterion, HasMarkersCriterion,
HasMarkersCriterionOption, HasMarkersCriterionOption,
@@ -115,6 +119,7 @@ export class ListFilterModel {
"title", "title",
"path", "path",
"rating", "rating",
"organized",
"o_counter", "o_counter",
"date", "date",
"filesize", "filesize",
@@ -134,6 +139,7 @@ export class ListFilterModel {
new NoneCriterionOption(), new NoneCriterionOption(),
ListFilterModel.createCriterionOption("path"), ListFilterModel.createCriterionOption("path"),
new RatingCriterionOption(), new RatingCriterionOption(),
new OrganizedCriterionOption(),
ListFilterModel.createCriterionOption("o_counter"), ListFilterModel.createCriterionOption("o_counter"),
new ResolutionCriterionOption(), new ResolutionCriterionOption(),
ListFilterModel.createCriterionOption("duration"), ListFilterModel.createCriterionOption("duration"),
@@ -161,6 +167,7 @@ export class ListFilterModel {
new NoneCriterionOption(), new NoneCriterionOption(),
ListFilterModel.createCriterionOption("path"), ListFilterModel.createCriterionOption("path"),
new RatingCriterionOption(), new RatingCriterionOption(),
new OrganizedCriterionOption(),
ListFilterModel.createCriterionOption("o_counter"), ListFilterModel.createCriterionOption("o_counter"),
new ResolutionCriterionOption(), new ResolutionCriterionOption(),
new ImageIsMissingCriterionOption(), new ImageIsMissingCriterionOption(),
@@ -234,6 +241,7 @@ export class ListFilterModel {
new NoneCriterionOption(), new NoneCriterionOption(),
ListFilterModel.createCriterionOption("path"), ListFilterModel.createCriterionOption("path"),
new RatingCriterionOption(), new RatingCriterionOption(),
new OrganizedCriterionOption(),
new AverageResolutionCriterionOption(), new AverageResolutionCriterionOption(),
new GalleryIsMissingCriterionOption(), new GalleryIsMissingCriterionOption(),
new TagsCriterionOption(), new TagsCriterionOption(),
@@ -435,6 +443,10 @@ export class ListFilterModel {
}; };
break; break;
} }
case "organized": {
result.organized = (criterion as OrganizedCriterion).value === "true";
break;
}
case "o_counter": { case "o_counter": {
const oCounterCrit = criterion as NumberCriterion; const oCounterCrit = criterion as NumberCriterion;
result.o_counter = { result.o_counter = {
@@ -669,6 +681,10 @@ export class ListFilterModel {
}; };
break; break;
} }
case "organized": {
result.organized = (criterion as OrganizedCriterion).value === "true";
break;
}
case "o_counter": { case "o_counter": {
const oCounterCrit = criterion as NumberCriterion; const oCounterCrit = criterion as NumberCriterion;
result.o_counter = { result.o_counter = {
@@ -800,6 +816,10 @@ export class ListFilterModel {
}; };
break; break;
} }
case "organized": {
result.organized = (criterion as OrganizedCriterion).value === "true";
break;
}
case "average_resolution": { case "average_resolution": {
switch ((criterion as AverageResolutionCriterion).value) { switch ((criterion as AverageResolutionCriterion).value) {
case "240p": case "240p":