From aadbcaeec22f701c343452c4b5e3dc323d9abd55 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 18 Dec 2020 08:06:49 +1100 Subject: [PATCH] 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 --- graphql/documents/data/gallery-slim.graphql | 1 + graphql/documents/data/gallery.graphql | 1 + graphql/documents/data/image-slim.graphql | 1 + graphql/documents/data/image.graphql | 1 + graphql/documents/data/scene-slim.graphql | 1 + graphql/documents/data/scene.graphql | 1 + graphql/documents/mutations/gallery.graphql | 1 + graphql/schema/types/filters.graphql | 6 +++ graphql/schema/types/gallery.graphql | 4 ++ graphql/schema/types/image.graphql | 3 ++ graphql/schema/types/scene.graphql | 3 ++ pkg/api/resolver_mutation_gallery.go | 2 + pkg/api/resolver_mutation_image.go | 2 + pkg/api/resolver_mutation_scene.go | 2 + pkg/database/database.go | 2 +- .../migrations/16_organized_flag.up.sql | 3 ++ pkg/gallery/export.go | 2 + pkg/gallery/export_test.go | 41 +++++++++--------- pkg/gallery/import.go | 1 + pkg/gallery/import_test.go | 22 +++++----- pkg/image/export.go | 1 + pkg/image/export_test.go | 41 +++++++++--------- pkg/image/import.go | 1 + pkg/manager/jsonschema/gallery.go | 1 + pkg/manager/jsonschema/image.go | 1 + pkg/manager/jsonschema/scene.go | 1 + pkg/manager/task_autotag.go | 9 ++-- pkg/manager/task_autotag_test.go | 10 +++++ pkg/models/model_gallery.go | 2 + pkg/models/model_image.go | 2 + pkg/models/model_scene.go | 2 + pkg/models/modelstest/sql.go | 7 ++++ pkg/models/querybuilder_gallery.go | 14 ++++++- pkg/models/querybuilder_image.go | 14 ++++++- pkg/models/querybuilder_scene.go | 20 +++++++-- pkg/scene/export.go | 1 + pkg/scene/export_test.go | 19 +++++---- pkg/scene/import.go | 1 + .../src/components/Changelog/versions/v050.md | 1 + .../Galleries/EditGalleriesDialog.tsx | 42 ++++++++++++++++++- .../src/components/Galleries/GalleryCard.tsx | 16 ++++++- .../Galleries/GalleryDetails/Gallery.tsx | 36 +++++++++++++++- .../components/Images/EditImagesDialog.tsx | 41 +++++++++++++++++- ui/v2.5/src/components/Images/ImageCard.tsx | 16 ++++++- .../components/Images/ImageDetails/Image.tsx | 31 ++++++++++++++ .../components/Scenes/EditScenesDialog.tsx | 41 +++++++++++++++++- ui/v2.5/src/components/Scenes/SceneCard.tsx | 16 ++++++- .../Scenes/SceneDetails/OrganizedButton.tsx | 40 ++++++++++++++++++ .../components/Scenes/SceneDetails/Scene.tsx | 30 +++++++++++++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 8 +++- ui/v2.5/src/components/Scenes/styles.scss | 10 +++++ ui/v2.5/src/core/StashService.ts | 5 +-- ui/v2.5/src/locale/en-GB.json | 1 + ui/v2.5/src/locale/en-US.json | 1 + .../models/list-filter/criteria/criterion.ts | 3 ++ .../models/list-filter/criteria/organized.ts | 16 +++++++ .../src/models/list-filter/criteria/utils.ts | 3 ++ ui/v2.5/src/models/list-filter/filter.ts | 20 +++++++++ 58 files changed, 543 insertions(+), 81 deletions(-) create mode 100644 pkg/database/migrations/16_organized_flag.up.sql create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/organized.ts diff --git a/graphql/documents/data/gallery-slim.graphql b/graphql/documents/data/gallery-slim.graphql index 1f99cc201..8b73aa3db 100644 --- a/graphql/documents/data/gallery-slim.graphql +++ b/graphql/documents/data/gallery-slim.graphql @@ -7,6 +7,7 @@ fragment GallerySlimData on Gallery { url details rating + organized image_count cover { ...SlimImageData diff --git a/graphql/documents/data/gallery.graphql b/graphql/documents/data/gallery.graphql index af99c77f7..188f08625 100644 --- a/graphql/documents/data/gallery.graphql +++ b/graphql/documents/data/gallery.graphql @@ -7,6 +7,7 @@ fragment GalleryData on Gallery { url details rating + organized images { ...SlimImageData } diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index 41789f5f5..ce80786ee 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -3,6 +3,7 @@ fragment SlimImageData on Image { checksum title rating + organized o_counter path diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index 9bd94b633..cf4d30e41 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -3,6 +3,7 @@ fragment ImageData on Image { checksum title rating + organized o_counter path diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 732c19280..3d3bf66b7 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -8,6 +8,7 @@ fragment SlimSceneData on Scene { date rating o_counter + organized path file { diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 43161efcb..e6ad62f20 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -8,6 +8,7 @@ fragment SceneData on Scene { date rating o_counter + organized path file { diff --git a/graphql/documents/mutations/gallery.graphql b/graphql/documents/mutations/gallery.graphql index 04565504e..28e0ac592 100644 --- a/graphql/documents/mutations/gallery.graphql +++ b/graphql/documents/mutations/gallery.graphql @@ -4,6 +4,7 @@ mutation GalleryCreate( $url: String, $date: String, $rating: Int, + $organized: Boolean, $scene_id: ID, $studio_id: ID, $performer_ids: [ID!] = [], diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 5b911d784..3af1310a1 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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""" diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index c39654a7e..04fc53805 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -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 diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index efb90ffaf..d3beab439 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -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 diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 23bacf926..2372331e5 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -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 diff --git a/pkg/api/resolver_mutation_gallery.go b/pkg/api/resolver_mutation_gallery.go index c20bc4b38..29c8c2d4d 100644 --- a/pkg/api/resolver_mutation_gallery.go +++ b/pkg/api/resolver_mutation_gallery.go @@ -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{} diff --git a/pkg/api/resolver_mutation_image.go b/pkg/api/resolver_mutation_image.go index 7d621cbe9..2d2ad4ada 100644 --- a/pkg/api/resolver_mutation_image.go +++ b/pkg/api/resolver_mutation_image.go @@ -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{} diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index d781636ac..aa0ff47a5 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -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{} diff --git a/pkg/database/database.go b/pkg/database/database.go index 39b62e017..389fe5bae 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -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" diff --git a/pkg/database/migrations/16_organized_flag.up.sql b/pkg/database/migrations/16_organized_flag.up.sql new file mode 100644 index 000000000..46395197c --- /dev/null +++ b/pkg/database/migrations/16_organized_flag.up.sql @@ -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'; diff --git a/pkg/gallery/export.go b/pkg/gallery/export.go index fd6999081..c9dd2797d 100644 --- a/pkg/gallery/export.go +++ b/pkg/gallery/export.go @@ -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 } diff --git a/pkg/gallery/export_test.go b/pkg/gallery/export_test.go index 439a116de..e0b29038a 100644 --- a/pkg/gallery/export_test.go +++ b/pkg/gallery/export_test.go @@ -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, }, diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 3643a2346..edc75d3b7 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -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()} diff --git a/pkg/gallery/import_test.go b/pkg/gallery/import_test.go index 6cbcbc32a..d88738190 100644 --- a/pkg/gallery/import_test.go +++ b/pkg/gallery/import_test.go @@ -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, }, diff --git a/pkg/image/export.go b/pkg/image/export.go index f75da1832..e02a505c4 100644 --- a/pkg/image/export.go +++ b/pkg/image/export.go @@ -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) diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 8bbc198b0..b1283298e 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -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, diff --git a/pkg/image/import.go b/pkg/image/import.go index b970be83d..a74b561b1 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -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()} diff --git a/pkg/manager/jsonschema/gallery.go b/pkg/manager/jsonschema/gallery.go index b128ee248..db6211b7b 100644 --- a/pkg/manager/jsonschema/gallery.go +++ b/pkg/manager/jsonschema/gallery.go @@ -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"` diff --git a/pkg/manager/jsonschema/image.go b/pkg/manager/jsonschema/image.go index d018ab2ab..118899ef8 100644 --- a/pkg/manager/jsonschema/image.go +++ b/pkg/manager/jsonschema/image.go @@ -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"` diff --git a/pkg/manager/jsonschema/scene.go b/pkg/manager/jsonschema/scene.go index cd9a066dd..c4e70cc8d 100644 --- a/pkg/manager/jsonschema/scene.go +++ b/pkg/manager/jsonschema/scene.go @@ -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"` diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 78f37ce69..8e4c4ab36 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -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()) diff --git a/pkg/manager/task_autotag_test.go b/pkg/manager/task_autotag_test.go index 690c056c3..67e428bbe 100644 --- a/pkg/manager/task_autotag_test.go +++ b/pkg/manager/task_autotag_test.go @@ -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)} diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index ed8809f79..db8383ad4 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -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"` diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 0da92c00f..6ad13eb2f 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -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"` diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 9d32bf874..5c036aee7 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -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"` diff --git a/pkg/models/modelstest/sql.go b/pkg/models/modelstest/sql.go index d60af2fed..dbc34bab5 100644 --- a/pkg/models/modelstest/sql.go +++ b/pkg/models/modelstest/sql.go @@ -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, + } +} diff --git a/pkg/models/querybuilder_gallery.go b/pkg/models/querybuilder_gallery.go index 6166bc73a..23c40138d 100644 --- a/pkg/models/querybuilder_gallery.go +++ b/pkg/models/querybuilder_gallery.go @@ -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": diff --git a/pkg/models/querybuilder_image.go b/pkg/models/querybuilder_image.go index f4b52032f..57aebf607 100644 --- a/pkg/models/querybuilder_image.go +++ b/pkg/models/querybuilder_image.go @@ -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 { diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index c22a77306..29ead2e89 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -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) diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 3b0f37f5f..c7e680eeb 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -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 { diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index 3c5919686..7ca7e2bb5 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -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, diff --git a/pkg/scene/import.go b/pkg/scene/import.go index a843646c2..1e634d31f 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -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()} diff --git a/ui/v2.5/src/components/Changelog/versions/v050.md b/ui/v2.5/src/components/Changelog/versions/v050.md index eaf07f18c..548f9dbb8 100644 --- a/ui/v2.5/src/components/Changelog/versions/v050.md +++ b/ui/v2.5/src/components/Changelog/versions/v050.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Add organized flag for scenes, galleries and images. * Allow configuration of visible navbar items. ### 🎨 Improvements diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 39973fc65..c61acd5ff 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -28,12 +28,15 @@ export const EditGalleriesDialog: React.FC = ( GQL.BulkUpdateIdMode.Add ); const [tagIds, setTagIds] = useState(); + const [organized, setOrganized] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); + const checkboxRef = React.createRef(); + function makeBulkUpdateIds( ids: string[], mode: GQL.BulkUpdateIdMode @@ -119,6 +122,10 @@ export const EditGalleriesDialog: React.FC = ( galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } + if (organized !== undefined) { + galleryInput.organized = organized; + } + return galleryInput; } @@ -223,6 +230,7 @@ export const EditGalleriesDialog: React.FC = ( 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 = ( updateStudioID = GalleriestudioID; updatePerformerIds = galleryPerformerIDs; updateTagIds = galleryTagIDs; + updateOrganized = gallery.organized; first = false; } else { if (galleryRating !== updateRating) { @@ -252,6 +261,9 @@ export const EditGalleriesDialog: React.FC = ( if (!_.isEqual(galleryTagIDs, updateTagIds)) { updateTagIds = []; } + if (gallery.organized !== updateOrganized) { + updateOrganized = undefined; + } } }); @@ -264,8 +276,16 @@ export const EditGalleriesDialog: React.FC = ( 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 = ( ); } + function cycleOrganized() { + if (organized) { + setOrganized(undefined); + } else if (organized === undefined) { + setOrganized(false); + } else { + setOrganized(true); + } + } + function render() { return ( = ( {renderMultiSelect("performers", performerIds)} - + Tags {renderMultiSelect("tags", tagIds)} + + + cycleOrganized()} + /> + ); diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index c73f5f9fb..af7e42aec 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -104,11 +104,24 @@ export const GalleryCard: React.FC = (props) => { ); } + function maybeRenderOrganized() { + if (props.gallery.organized) { + return ( +
+ +
+ ); + } + } + 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 = (props) => { {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderScenePopoverButton()} + {maybeRenderOrganized()} ); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 6b6100f8e..9bd621dc9 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -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(); 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(false); function onDeleteDialogClosed(deleted: boolean) { @@ -103,7 +128,14 @@ export const Gallery: React.FC = () => { Edit - {renderOperations()} + + + + {renderOperations()} diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 41776b143..4c542d35a 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -28,12 +28,15 @@ export const EditImagesDialog: React.FC = ( GQL.BulkUpdateIdMode.Add ); const [tagIds, setTagIds] = useState(); + const [organized, setOrganized] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); + const checkboxRef = React.createRef(); + function makeBulkUpdateIds( ids: string[], mode: GQL.BulkUpdateIdMode @@ -119,6 +122,10 @@ export const EditImagesDialog: React.FC = ( imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } + if (organized !== undefined) { + imageInput.organized = organized; + } + return imageInput; } @@ -221,6 +228,7 @@ export const EditImagesDialog: React.FC = ( 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 = ( updateStudioID = imageStudioID; updatePerformerIds = imagePerformerIDs; updateTagIds = imageTagIDs; + updateOrganized = image.organized; first = false; } else { if (imageRating !== updateRating) { @@ -250,6 +259,9 @@ export const EditImagesDialog: React.FC = ( if (!_.isEqual(imageTagIDs, updateTagIds)) { updateTagIds = []; } + if (image.organized !== updateOrganized) { + updateOrganized = undefined; + } } }); @@ -262,8 +274,15 @@ export const EditImagesDialog: React.FC = ( 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 = ( ); } + function cycleOrganized() { + if (organized) { + setOrganized(undefined); + } else if (organized === undefined) { + setOrganized(false); + } else { + setOrganized(true); + } + } + function render() { return ( = ( {renderMultiSelect("performers", performerIds)} - + Tags {renderMultiSelect("tags", tagIds)} + + + cycleOrganized()} + /> + ); diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 71e38fcca..d40dda346 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -93,11 +93,24 @@ export const ImageCard: React.FC = ( } } + function maybeRenderOrganized() { + if (props.image.organized) { + return ( +
+ +
+ ); + } + } + 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 = ( {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderOCounter()} + {maybeRenderOrganized()} ); diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index f61f2bbba..413c17b76 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -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(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} /> + + + {renderOperations()} diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index c3bc35360..3ab34f1f2 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -28,12 +28,15 @@ export const EditScenesDialog: React.FC = ( GQL.BulkUpdateIdMode.Add ); const [tagIds, setTagIds] = useState(); + const [organized, setOrganized] = useState(); const [updateScenes] = useBulkSceneUpdate(getSceneInput()); // Network state const [isUpdating, setIsUpdating] = useState(false); + const checkboxRef = React.createRef(); + function makeBulkUpdateIds( ids: string[], mode: GQL.BulkUpdateIdMode @@ -119,6 +122,10 @@ export const EditScenesDialog: React.FC = ( sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } + if (organized !== undefined) { + sceneInput.organized = organized; + } + return sceneInput; } @@ -217,6 +224,7 @@ export const EditScenesDialog: React.FC = ( 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 = ( updatePerformerIds = scenePerformerIDs; updateTagIds = sceneTagIDs; first = false; + updateOrganized = scene.organized; } else { if (sceneRating !== updateRating) { updateRating = undefined; @@ -246,6 +255,9 @@ export const EditScenesDialog: React.FC = ( if (!_.isEqual(sceneTagIDs, updateTagIds)) { updateTagIds = []; } + if (scene.organized !== updateOrganized) { + updateOrganized = undefined; + } } }); @@ -258,8 +270,15 @@ export const EditScenesDialog: React.FC = ( 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 = ( ); } + function cycleOrganized() { + if (organized) { + setOrganized(undefined); + } else if (organized === undefined) { + setOrganized(false); + } else { + setOrganized(true); + } + } + function render() { return ( = ( {renderMultiSelect("performers", performerIds)} - + Tags {renderMultiSelect("tags", tagIds)} + + + cycleOrganized()} + /> + ); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index bbcbe4f6a..495eef3c3 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -267,6 +267,18 @@ export const SceneCard: React.FC = ( } } + function maybeRenderOrganized() { + if (props.scene.organized) { + return ( +
+ +
+ ); + } + } + function maybeRenderPopoverButtonGroup() { if ( props.scene.tags.length > 0 || @@ -274,7 +286,8 @@ export const SceneCard: React.FC = ( 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 = ( {maybeRenderSceneMarkerPopoverButton()} {maybeRenderOCounter()} {maybeRenderGallery()} + {maybeRenderOrganized()} ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx new file mode 100644 index 000000000..84a4abae2 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx @@ -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 = ( + props: IOrganizedButtonProps +) => { + const intl = useIntl(); + const messages = defineMessages({ + organized: { + id: "organized", + defaultMessage: "Organized", + }, + }); + + if (props.loading) return ; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index a042ae651..4f1ca2d66 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -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(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(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} /> + + + {renderOperations()} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index de5532f29..a2051f131 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -70,7 +70,7 @@ export const SceneEditPanel: React.FC = (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 = (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" }); } diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index a9e56ecd1..73538c968 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -535,3 +535,13 @@ input[type="range"].blue-slider { } } } + +.organized-button { + &.not-organized { + color: rgba(191, 204, 214, 0.5); + } + + &.organized { + color: #664c3f; + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index abbbe404b..0ff0b6614 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -323,11 +323,8 @@ const sceneMutationImpactedQueries = [ GQL.AllTagsDocument, ]; -export const useSceneUpdate = (input: GQL.SceneUpdateInput) => +export const useSceneUpdate = () => GQL.useSceneUpdateMutation({ - variables: { - input, - }, update: deleteCache(sceneMutationImpactedQueries), }); diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index 2e2c7e7ae..698445cc4 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -6,6 +6,7 @@ "markers": "Markers", "movies": "Movies", "new": "New", + "organized": "Organised", "performers": "Performers", "scenes": "Scenes", "studios": "Studios", diff --git a/ui/v2.5/src/locale/en-US.json b/ui/v2.5/src/locale/en-US.json index a1a9e2742..fd6fc3098 100644 --- a/ui/v2.5/src/locale/en-US.json +++ b/ui/v2.5/src/locale/en-US.json @@ -6,6 +6,7 @@ "markers": "Markers", "movies": "Movies", "new": "New", + "organized": "Organized", "performers": "Performers", "scenes": "Scenes", "studios": "Studios", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index b7f43c1ab..c3d710316 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/criteria/organized.ts b/ui/v2.5/src/models/list-filter/criteria/organized.ts new file mode 100644 index 000000000..287b8b94a --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/organized.ts @@ -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"; +} diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 0e3d95a47..21fca12da 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index b58e0429e..ae58e37b8 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -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":