diff --git a/gqlgen.yml b/gqlgen.yml index 2b2402034..eab0a4db9 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -52,5 +52,7 @@ models: model: github.com/stashapp/stash/pkg/models.ScrapedMovie ScrapedMovieStudio: model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio + SavedFilter: + model: github.com/stashapp/stash/pkg/models.SavedFilter StashID: model: github.com/stashapp/stash/pkg/models.StashID diff --git a/graphql/documents/data/filter.graphql b/graphql/documents/data/filter.graphql new file mode 100644 index 000000000..39a3d080e --- /dev/null +++ b/graphql/documents/data/filter.graphql @@ -0,0 +1,6 @@ +fragment SavedFilterData on SavedFilter { + id + mode + name + filter +} \ No newline at end of file diff --git a/graphql/documents/mutations/filter.graphql b/graphql/documents/mutations/filter.graphql new file mode 100644 index 000000000..f529f56e9 --- /dev/null +++ b/graphql/documents/mutations/filter.graphql @@ -0,0 +1,13 @@ +mutation SaveFilter($input: SaveFilterInput!) { + saveFilter(input: $input) { + ...SavedFilterData + } +} + +mutation DestroySavedFilter($input: DestroyFilterInput!) { + destroySavedFilter(input: $input) +} + +mutation SetDefaultFilter($input: SetDefaultFilterInput!) { + setDefaultFilter(input: $input) +} diff --git a/graphql/documents/queries/filter.graphql b/graphql/documents/queries/filter.graphql new file mode 100644 index 000000000..2c022fde7 --- /dev/null +++ b/graphql/documents/queries/filter.graphql @@ -0,0 +1,11 @@ +query FindSavedFilters($mode: FilterMode!) { + findSavedFilters(mode: $mode) { + ...SavedFilterData + } +} + +query FindDefaultFilter($mode: FilterMode!) { + findDefaultFilter(mode: $mode) { + ...SavedFilterData + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 623e4aeb8..5ac192854 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -1,5 +1,9 @@ """The query root for this schema""" type Query { + # Filters + findSavedFilters(mode: FilterMode!): [SavedFilter!]! + findDefaultFilter(mode: FilterMode!): SavedFilter + """Find a scene by ID or Checksum""" findScene(id: ID, checksum: String): Scene findSceneByHash(input: SceneHashInput!): Scene @@ -199,6 +203,11 @@ type Mutation { tagsDestroy(ids: [ID!]!): Boolean! tagsMerge(input: TagsMergeInput!): Tag + # Saved filters + saveFilter(input: SaveFilterInput!): SavedFilter! + destroySavedFilter(input: DestroyFilterInput!): Boolean! + setDefaultFilter(input: SetDefaultFilterInput!): Boolean! + """Change general configuration options""" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d6466ec83..0e48063aa 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -317,3 +317,41 @@ input HierarchicalMultiCriterionInput { modifier: CriterionModifier! depth: Int! } + +enum FilterMode { + SCENES, + PERFORMERS, + STUDIOS, + GALLERIES, + SCENE_MARKERS, + MOVIES, + TAGS, + IMAGES, +} + +type SavedFilter { + id: ID! + mode: FilterMode! + name: String! + """JSON-encoded filter string""" + filter: String! +} + +input SaveFilterInput { + """provide ID to overwrite existing filter""" + id: ID + mode: FilterMode! + name: String! + """JSON-encoded filter string""" + filter: String! +} + +input DestroyFilterInput { + id: ID! +} + +input SetDefaultFilterInput { + mode: FilterMode! + """JSON-encoded filter string - null to clear""" + filter: String +} diff --git a/pkg/api/resolver_mutation_saved_filter.go b/pkg/api/resolver_mutation_saved_filter.go new file mode 100644 index 000000000..f8467cb5e --- /dev/null +++ b/pkg/api/resolver_mutation_saved_filter.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "errors" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *mutationResolver) SaveFilter(ctx context.Context, input models.SaveFilterInput) (ret *models.SavedFilter, err error) { + if strings.TrimSpace(input.Name) == "" { + return nil, errors.New("name must be non-empty") + } + + var id *int + if input.ID != nil { + idv, err := strconv.Atoi(*input.ID) + if err != nil { + return nil, err + } + id = &idv + } + + if err := r.withTxn(ctx, func(repo models.Repository) error { + f := models.SavedFilter{ + Mode: input.Mode, + Name: input.Name, + Filter: input.Filter, + } + if id == nil { + ret, err = repo.SavedFilter().Create(f) + } else { + f.ID = *id + ret, err = repo.SavedFilter().Update(f) + } + return err + }); err != nil { + return nil, err + } + return ret, err +} + +func (r *mutationResolver) DestroySavedFilter(ctx context.Context, input models.DestroyFilterInput) (bool, error) { + id, err := strconv.Atoi(input.ID) + if err != nil { + return false, err + } + + if err := r.withTxn(ctx, func(repo models.Repository) error { + return repo.SavedFilter().Destroy(id) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) SetDefaultFilter(ctx context.Context, input models.SetDefaultFilterInput) (bool, error) { + if err := r.withTxn(ctx, func(repo models.Repository) error { + qb := repo.SavedFilter() + + if input.Filter == nil { + // clearing + def, err := qb.FindDefault(input.Mode) + if err != nil { + return err + } + + if def != nil { + return qb.Destroy(def.ID) + } + + return nil + } + + _, err := qb.SetDefault(models.SavedFilter{ + Mode: input.Mode, + Filter: *input.Filter, + }) + + return err + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/pkg/api/resolver_query_find_saved_filter.go b/pkg/api/resolver_query_find_saved_filter.go new file mode 100644 index 000000000..d79697701 --- /dev/null +++ b/pkg/api/resolver_query_find_saved_filter.go @@ -0,0 +1,27 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) FindSavedFilters(ctx context.Context, mode models.FilterMode) (ret []*models.SavedFilter, err error) { + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.SavedFilter().FindByMode(mode) + return err + }); err != nil { + return nil, err + } + return ret, err +} + +func (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.FilterMode) (ret *models.SavedFilter, err error) { + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.SavedFilter().FindDefault(mode) + return err + }); err != nil { + return nil, err + } + return ret, err +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 4ee66e82e..c992601d8 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 24 +var appSchemaVersion uint = 25 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/25_saved_filters.up.sql b/pkg/database/migrations/25_saved_filters.up.sql new file mode 100644 index 000000000..5739ae408 --- /dev/null +++ b/pkg/database/migrations/25_saved_filters.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE `saved_filters` ( + `id` integer not null primary key autoincrement, + `name` varchar(510) not null, + `mode` varchar(255) not null, + `filter` blob not null +); + +CREATE UNIQUE INDEX `index_saved_filters_on_mode_name_unique` on `saved_filters` (`mode`, `name`); diff --git a/pkg/models/mocks/SavedFilterReaderWriter.go b/pkg/models/mocks/SavedFilterReaderWriter.go new file mode 100644 index 000000000..ce8f40546 --- /dev/null +++ b/pkg/models/mocks/SavedFilterReaderWriter.go @@ -0,0 +1,165 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + models "github.com/stashapp/stash/pkg/models" + mock "github.com/stretchr/testify/mock" +) + +// SavedFilterReaderWriter is an autogenerated mock type for the SavedFilterReaderWriter type +type SavedFilterReaderWriter struct { + mock.Mock +} + +// Create provides a mock function with given fields: obj +func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) { + ret := _m.Called(obj) + + var r0 *models.SavedFilter + if rf, ok := ret.Get(0).(func(models.SavedFilter) *models.SavedFilter); ok { + r0 = rf(obj) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.SavedFilter) error); ok { + r1 = rf(obj) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Destroy provides a mock function with given fields: id +func (_m *SavedFilterReaderWriter) Destroy(id int) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(int) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: id +func (_m *SavedFilterReaderWriter) Find(id int) (*models.SavedFilter, error) { + ret := _m.Called(id) + + var r0 *models.SavedFilter + if rf, ok := ret.Get(0).(func(int) *models.SavedFilter); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByMode provides a mock function with given fields: mode +func (_m *SavedFilterReaderWriter) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) { + ret := _m.Called(mode) + + var r0 []*models.SavedFilter + if rf, ok := ret.Get(0).(func(models.FilterMode) []*models.SavedFilter); ok { + r0 = rf(mode) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.FilterMode) error); ok { + r1 = rf(mode) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindDefault provides a mock function with given fields: mode +func (_m *SavedFilterReaderWriter) FindDefault(mode models.FilterMode) (*models.SavedFilter, error) { + ret := _m.Called(mode) + + var r0 *models.SavedFilter + if rf, ok := ret.Get(0).(func(models.FilterMode) *models.SavedFilter); ok { + r0 = rf(mode) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.FilterMode) error); ok { + r1 = rf(mode) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetDefault provides a mock function with given fields: obj +func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) { + ret := _m.Called(obj) + + var r0 *models.SavedFilter + if rf, ok := ret.Get(0).(func(models.SavedFilter) *models.SavedFilter); ok { + r0 = rf(obj) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.SavedFilter) error); ok { + r1 = rf(obj) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: obj +func (_m *SavedFilterReaderWriter) Update(obj models.SavedFilter) (*models.SavedFilter, error) { + ret := _m.Called(obj) + + var r0 *models.SavedFilter + if rf, ok := ret.Get(0).(func(models.SavedFilter) *models.SavedFilter); ok { + r0 = rf(obj) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SavedFilter) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.SavedFilter) error); ok { + r1 = rf(obj) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/models/mocks/transaction.go b/pkg/models/mocks/transaction.go index 9b4a19feb..da6e2e333 100644 --- a/pkg/models/mocks/transaction.go +++ b/pkg/models/mocks/transaction.go @@ -16,6 +16,7 @@ type TransactionManager struct { scrapedItem models.ScrapedItemReaderWriter studio models.StudioReaderWriter tag models.TagReaderWriter + savedFilter models.SavedFilterReaderWriter } func NewTransactionManager() *TransactionManager { @@ -29,6 +30,7 @@ func NewTransactionManager() *TransactionManager { scrapedItem: &ScrapedItemReaderWriter{}, studio: &StudioReaderWriter{}, tag: &TagReaderWriter{}, + savedFilter: &SavedFilterReaderWriter{}, } } @@ -72,6 +74,10 @@ func (t *TransactionManager) Tag() models.TagReaderWriter { return t.tag } +func (t *TransactionManager) SavedFilter() models.SavedFilterReaderWriter { + return t.savedFilter +} + type ReadTransaction struct { t *TransactionManager } @@ -115,3 +121,7 @@ func (r *ReadTransaction) Studio() models.StudioReader { func (r *ReadTransaction) Tag() models.TagReader { return r.t.tag } + +func (r *ReadTransaction) SavedFilter() models.SavedFilterReader { + return r.t.savedFilter +} diff --git a/pkg/models/model_saved_filter.go b/pkg/models/model_saved_filter.go new file mode 100644 index 000000000..5acd6d8f8 --- /dev/null +++ b/pkg/models/model_saved_filter.go @@ -0,0 +1,19 @@ +package models + +type SavedFilter struct { + ID int `db:"id" json:"id"` + Mode FilterMode `db:"mode" json:"mode"` + Name string `db:"name" json:"name"` + // JSON-encoded filter string + Filter string `db:"filter" json:"filter"` +} + +type SavedFilters []*SavedFilter + +func (m *SavedFilters) Append(o interface{}) { + *m = append(*m, o.(*SavedFilter)) +} + +func (m *SavedFilters) New() interface{} { + return &SavedFilter{} +} diff --git a/pkg/models/repository.go b/pkg/models/repository.go index dfed77453..2686d7c3a 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -10,6 +10,7 @@ type Repository interface { ScrapedItem() ScrapedItemReaderWriter Studio() StudioReaderWriter Tag() TagReaderWriter + SavedFilter() SavedFilterReaderWriter } type ReaderRepository interface { @@ -22,4 +23,5 @@ type ReaderRepository interface { ScrapedItem() ScrapedItemReader Studio() StudioReader Tag() TagReader + SavedFilter() SavedFilterReader } diff --git a/pkg/models/saved_filter.go b/pkg/models/saved_filter.go new file mode 100644 index 000000000..e455d92c4 --- /dev/null +++ b/pkg/models/saved_filter.go @@ -0,0 +1,19 @@ +package models + +type SavedFilterReader interface { + Find(id int) (*SavedFilter, error) + FindByMode(mode FilterMode) ([]*SavedFilter, error) + FindDefault(mode FilterMode) (*SavedFilter, error) +} + +type SavedFilterWriter interface { + Create(obj SavedFilter) (*SavedFilter, error) + Update(obj SavedFilter) (*SavedFilter, error) + SetDefault(obj SavedFilter) (*SavedFilter, error) + Destroy(id int) error +} + +type SavedFilterReaderWriter interface { + SavedFilterReader + SavedFilterWriter +} diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go new file mode 100644 index 000000000..c4bbf0f8e --- /dev/null +++ b/pkg/sqlite/saved_filter.go @@ -0,0 +1,109 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +const savedFilterTable = "saved_filters" +const savedFilterDefaultName = "" + +type savedFilterQueryBuilder struct { + repository +} + +func NewSavedFilterReaderWriter(tx dbi) *savedFilterQueryBuilder { + return &savedFilterQueryBuilder{ + repository{ + tx: tx, + tableName: savedFilterTable, + idColumn: idColumn, + }, + } +} + +func (qb *savedFilterQueryBuilder) Create(newObject models.SavedFilter) (*models.SavedFilter, error) { + var ret models.SavedFilter + if err := qb.insertObject(newObject, &ret); err != nil { + return nil, err + } + + return &ret, nil +} + +func (qb *savedFilterQueryBuilder) Update(updatedObject models.SavedFilter) (*models.SavedFilter, error) { + const partial = false + if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil { + return nil, err + } + + var ret models.SavedFilter + if err := qb.get(updatedObject.ID, &ret); err != nil { + return nil, err + } + + return &ret, nil +} + +func (qb *savedFilterQueryBuilder) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) { + // find the existing default + existing, err := qb.FindDefault(obj.Mode) + + if err != nil { + return nil, err + } + + obj.Name = savedFilterDefaultName + + if existing != nil { + obj.ID = existing.ID + return qb.Update(obj) + } + + return qb.Create(obj) +} + +func (qb *savedFilterQueryBuilder) Destroy(id int) error { + return qb.destroyExisting([]int{id}) +} + +func (qb *savedFilterQueryBuilder) Find(id int) (*models.SavedFilter, error) { + var ret models.SavedFilter + if err := qb.get(id, &ret); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (qb *savedFilterQueryBuilder) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) { + // exclude empty-named filters - these are the internal default filters + + query := fmt.Sprintf(`SELECT * FROM %s WHERE mode = ? AND name != ?`, savedFilterTable) + + var ret models.SavedFilters + if err := qb.query(query, []interface{}{mode, savedFilterDefaultName}, &ret); err != nil { + return nil, err + } + + return []*models.SavedFilter(ret), nil +} + +func (qb *savedFilterQueryBuilder) FindDefault(mode models.FilterMode) (*models.SavedFilter, error) { + query := fmt.Sprintf(`SELECT * FROM %s WHERE mode = ? AND name = ?`, savedFilterTable) + + var ret models.SavedFilters + if err := qb.query(query, []interface{}{mode, savedFilterDefaultName}, &ret); err != nil { + return nil, err + } + + if len(ret) > 0 { + return ret[0], nil + } + + return nil, nil +} diff --git a/pkg/sqlite/saved_filter_test.go b/pkg/sqlite/saved_filter_test.go new file mode 100644 index 000000000..4cd33c97a --- /dev/null +++ b/pkg/sqlite/saved_filter_test.go @@ -0,0 +1,123 @@ +// +build integration + +package sqlite_test + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestSavedFilterFind(t *testing.T) { + withTxn(func(r models.Repository) error { + savedFilter, err := r.SavedFilter().Find(savedFilterIDs[savedFilterIdxImage]) + + if err != nil { + t.Errorf("Error finding saved filter: %s", err.Error()) + } + + assert.Equal(t, savedFilterIDs[savedFilterIdxImage], savedFilter.ID) + + return nil + }) +} + +func TestSavedFilterFindByMode(t *testing.T) { + withTxn(func(r models.Repository) error { + savedFilters, err := r.SavedFilter().FindByMode(models.FilterModeScenes) + + if err != nil { + t.Errorf("Error finding saved filters: %s", err.Error()) + } + + assert.Len(t, savedFilters, 1) + assert.Equal(t, savedFilterIDs[savedFilterIdxScene], savedFilters[0].ID) + + return nil + }) +} + +func TestSavedFilterDestroy(t *testing.T) { + const filterName = "filterToDestroy" + const testFilter = "{}" + var id int + + // create the saved filter to destroy + withTxn(func(r models.Repository) error { + created, err := r.SavedFilter().Create(models.SavedFilter{ + Name: filterName, + Mode: models.FilterModeScenes, + Filter: testFilter, + }) + + if err == nil { + id = created.ID + } + + return err + }) + + withTxn(func(r models.Repository) error { + qb := r.SavedFilter() + + return qb.Destroy(id) + }) + + // now try to find it + withTxn(func(r models.Repository) error { + found, err := r.SavedFilter().Find(id) + if err == nil { + assert.Nil(t, found) + } + + return err + }) +} + +func TestSavedFilterFindDefault(t *testing.T) { + withTxn(func(r models.Repository) error { + def, err := r.SavedFilter().FindDefault(models.FilterModeScenes) + if err == nil { + assert.Equal(t, savedFilterIDs[savedFilterIdxDefaultScene], def.ID) + } + + return err + }) +} + +func TestSavedFilterSetDefault(t *testing.T) { + const newFilter = "foo" + + withTxn(func(r models.Repository) error { + _, err := r.SavedFilter().SetDefault(models.SavedFilter{ + Mode: models.FilterModeMovies, + Filter: newFilter, + }) + + return err + }) + + var defID int + withTxn(func(r models.Repository) error { + def, err := r.SavedFilter().FindDefault(models.FilterModeMovies) + if err == nil { + defID = def.ID + assert.Equal(t, newFilter, def.Filter) + } + + return err + }) + + // destroy it again + withTxn(func(r models.Repository) error { + return r.SavedFilter().Destroy(defID) + }) +} + +// TODO Update +// TODO Destroy +// TODO Find +// TODO GetMarkerStrings +// TODO Wall +// TODO Query diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 67d805ea5..1cd30fd5f 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -189,22 +189,34 @@ const ( ) const ( - pathField = "Path" - checksumField = "Checksum" - titleField = "Title" - urlField = "URL" - zipPath = "zipPath.zip" + savedFilterIdxDefaultScene = iota + savedFilterIdxDefaultImage + savedFilterIdxScene + savedFilterIdxImage + + // new indexes above + totalSavedFilters +) + +const ( + pathField = "Path" + checksumField = "Checksum" + titleField = "Title" + urlField = "URL" + zipPath = "zipPath.zip" + firstSavedFilterName = "firstSavedFilterName" ) var ( - sceneIDs []int - imageIDs []int - performerIDs []int - movieIDs []int - galleryIDs []int - tagIDs []int - studioIDs []int - markerIDs []int + sceneIDs []int + imageIDs []int + performerIDs []int + movieIDs []int + galleryIDs []int + tagIDs []int + studioIDs []int + markerIDs []int + savedFilterIDs []int tagNames []string studioNames []string @@ -423,6 +435,10 @@ func populateDB() error { return fmt.Errorf("error creating studios: %s", err.Error()) } + if err := createSavedFilters(r.SavedFilter(), totalSavedFilters); err != nil { + return fmt.Errorf("error creating saved filters: %s", err.Error()) + } + if err := linkPerformerTags(r.Performer()); err != nil { return fmt.Errorf("error linking performer tags: %s", err.Error()) } @@ -979,6 +995,51 @@ func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx in return nil } +func getSavedFilterMode(index int) models.FilterMode { + switch index { + case savedFilterIdxScene, savedFilterIdxDefaultScene: + return models.FilterModeScenes + case savedFilterIdxImage, savedFilterIdxDefaultImage: + return models.FilterModeImages + default: + return models.FilterModeScenes + } +} + +func getSavedFilterName(index int) string { + if index <= savedFilterIdxDefaultImage { + // empty string for default filters + return "" + } + + if index <= savedFilterIdxImage { + // use the same name for the first two - should be possible + return firstSavedFilterName + } + + return getPrefixedStringValue("savedFilter", index, "Name") +} + +func createSavedFilters(qb models.SavedFilterReaderWriter, n int) error { + for i := 0; i < n; i++ { + savedFilter := models.SavedFilter{ + Mode: getSavedFilterMode(i), + Name: getSavedFilterName(i), + Filter: getPrefixedStringValue("savedFilter", i, "Filter"), + } + + created, err := qb.Create(savedFilter) + + if err != nil { + return fmt.Errorf("Error creating saved filter %v+: %s", savedFilter, err.Error()) + } + + savedFilterIDs = append(savedFilterIDs, created.ID) + } + + return nil +} + func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { for _, l := range links { if err := fn(l[0], l[1]); err != nil { diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index b9d3157ff..0eb45d5f9 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -125,6 +125,11 @@ func (t *transaction) Tag() models.TagReaderWriter { return NewTagReaderWriter(t.tx) } +func (t *transaction) SavedFilter() models.SavedFilterReaderWriter { + t.ensureTx() + return NewSavedFilterReaderWriter(t.tx) +} + type ReadTransaction struct{} func (t *ReadTransaction) Begin() error { @@ -183,6 +188,10 @@ func (t *ReadTransaction) Tag() models.TagReader { return NewTagReaderWriter(database.DB) } +func (t *ReadTransaction) SavedFilter() models.SavedFilterReader { + return NewSavedFilterReaderWriter(database.DB) +} + type TransactionManager struct { } diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index a80ff31b0..4f896db54 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added support for saved and default filters. ([#1474](https://github.com/stashapp/stash/pull/1474)) * Added merge tags functionality. ([#1481](https://github.com/stashapp/stash/pull/1481)) * Added support for triggering plugin tasks during operations. ([#1452](https://github.com/stashapp/stash/pull/1452)) * Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397)) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 9557376dc..10b710795 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -24,7 +24,7 @@ export const GalleryAddPanel: React.FC = ({ gallery }) => { }; // if galleries is already present, then we modify it, otherwise add let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "galleries"; + return c.criterionOption.type === "galleries"; }) as GalleriesCriterion; if ( diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index 25d4bace7..0e3b1c662 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -26,7 +26,7 @@ export const GalleryImagesPanel: React.FC = ({ }; // if galleries is already present, then we modify it, otherwise add let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "galleries"; + return c.criterionOption.type === "galleries"; }) as GalleriesCriterion; if ( diff --git a/ui/v2.5/src/components/List/AddFilter.tsx b/ui/v2.5/src/components/List/AddFilterDialog.tsx similarity index 75% rename from ui/v2.5/src/components/List/AddFilter.tsx rename to ui/v2.5/src/components/List/AddFilterDialog.tsx index eb79263cd..c58c1a276 100644 --- a/ui/v2.5/src/components/List/AddFilter.tsx +++ b/ui/v2.5/src/components/List/AddFilterDialog.tsx @@ -1,8 +1,7 @@ import _ from "lodash"; import React, { useEffect, useRef, useState } from "react"; -import { Button, Form, Modal, OverlayTrigger, Tooltip } from "react-bootstrap"; -import Mousetrap from "mousetrap"; -import { Icon, FilterSelect, DurationInput } from "src/components/Shared"; +import { Button, Form, Modal } from "react-bootstrap"; +import { FilterSelect, DurationInput } from "src/components/Shared"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationCriterion, @@ -10,7 +9,10 @@ import { Criterion, IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; -import { NoneCriterion } from "src/models/list-filter/criteria/none"; +import { + NoneCriterion, + NoneCriterionOption, +} from "src/models/list-filter/criteria/none"; import { makeCriteria } from "src/models/list-filter/criteria/factory"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { defineMessages, FormattedMessage, useIntl } from "react-intl"; @@ -29,15 +31,18 @@ interface IAddFilterProps { editingCriterion?: Criterion; } -export const AddFilter: React.FC = ( - props: IAddFilterProps -) => { +export const AddFilterDialog: React.FC = ({ + onAddCriterion, + onCancel, + filterOptions, + editingCriterion, +}) => { const defaultValue = useRef(); - const [isOpen, setIsOpen] = useState(false); const [criterion, setCriterion] = useState>( new NoneCriterion() ); + const { options, modifierOptions } = criterion.criterionOption; const valueStage = useRef(criterion.value); @@ -50,23 +55,14 @@ export const AddFilter: React.FC = ( }, }); - // configure keyboard shortcuts - useEffect(() => { - Mousetrap.bind("f", () => setIsOpen(true)); - - return () => { - Mousetrap.unbind("f"); - }; - }); - // Configure if we are editing an existing criterion useEffect(() => { - if (!props.editingCriterion) { - return; + if (!editingCriterion) { + setCriterion(makeCriteria()); + } else { + setCriterion(editingCriterion); } - setIsOpen(true); - setCriterion(props.editingCriterion); - }, [props.editingCriterion]); + }, [editingCriterion]); function onChangedCriteriaType(event: React.ChangeEvent) { const newCriterionType = event.target.value as CriterionType; @@ -107,38 +103,27 @@ export const AddFilter: React.FC = ( if (!Array.isArray(criterion.value) && defaultValue.current !== undefined) { const value = defaultValue.current; if ( - criterion.options && + options && (value === undefined || value === "" || typeof value === "number") ) { - criterion.value = criterion.options[0].toString(); + criterion.value = options[0].toString(); } else if (typeof value === "number" && value === undefined) { criterion.value = 0; } else if (value === undefined) { criterion.value = ""; } } - const oldId = props.editingCriterion - ? props.editingCriterion.getId() - : undefined; - props.onAddCriterion(criterion, oldId); - onToggle(); - } - - function onToggle() { - if (isOpen) { - props.onCancel(); - } - setIsOpen(!isOpen); - setCriterion(makeCriteria()); + const oldId = editingCriterion ? editingCriterion.getId() : undefined; + onAddCriterion(criterion, oldId); } const maybeRenderFilterPopoverContents = () => { - if (criterion.criterionOption.value === "none") { + if (criterion.criterionOption.type === "none") { return; } function renderModifier() { - if (criterion.modifierOptions.length === 0) { + if (modifierOptions.length === 0) { return; } return ( @@ -148,9 +133,9 @@ export const AddFilter: React.FC = ( value={criterion.modifier} className="btn-secondary" > - {criterion.modifierOptions.map((c) => ( + {modifierOptions.map((c) => ( ))} @@ -168,19 +153,19 @@ export const AddFilter: React.FC = ( if (Array.isArray(criterion.value)) { if ( - criterion.criterionOption.value !== "performers" && - criterion.criterionOption.value !== "studios" && - criterion.criterionOption.value !== "parent_studios" && - criterion.criterionOption.value !== "tags" && - criterion.criterionOption.value !== "sceneTags" && - criterion.criterionOption.value !== "performerTags" && - criterion.criterionOption.value !== "movies" + criterion.criterionOption.type !== "performers" && + criterion.criterionOption.type !== "studios" && + criterion.criterionOption.type !== "parent_studios" && + criterion.criterionOption.type !== "tags" && + criterion.criterionOption.type !== "sceneTags" && + criterion.criterionOption.type !== "performerTags" && + criterion.criterionOption.type !== "movies" ) return; return ( { const newCriterion = _.cloneDeep(criterion); @@ -195,11 +180,11 @@ export const AddFilter: React.FC = ( ); } if (criterion instanceof IHierarchicalLabeledIdCriterion) { - if (criterion.criterionOption.value !== "studios") return; + if (criterion.criterionOption.type !== "studios") return; return ( { const newCriterion = _.cloneDeep(criterion); @@ -213,10 +198,7 @@ export const AddFilter: React.FC = ( /> ); } - if ( - criterion.options && - !criterionIsHierarchicalLabelValue(criterion.value) - ) { + if (options && !criterionIsHierarchicalLabelValue(criterion.value)) { defaultValue.current = criterion.value; return ( = ( value={criterion.value.toString()} className="btn-secondary" > - {criterion.options.map((c) => ( + {options.map((c) => ( @@ -245,7 +227,7 @@ export const AddFilter: React.FC = ( return ( = ( { const newCriterion = _.cloneDeep(criterion); newCriterion.value.depth = @@ -304,7 +286,7 @@ export const AddFilter: React.FC = ( }; function maybeRenderFilterCriterion() { - if (!props.editingCriterion) { + if (!editingCriterion) { return; } @@ -312,7 +294,7 @@ export const AddFilter: React.FC = ( {intl.formatMessage({ - id: props.editingCriterion.criterionOption.messageID, + id: editingCriterion.criterionOption.messageID, })} @@ -320,14 +302,15 @@ export const AddFilter: React.FC = ( } function maybeRenderFilterSelect() { - if (props.editingCriterion) { + if (editingCriterion) { return; } - const options = props.filterOptions.criterionOptions + const thisOptions = [NoneCriterionOption] + .concat(filterOptions.criterionOptions) .map((c) => { return { - value: c.value, + value: c.type, text: intl.formatMessage({ id: c.messageID }), }; }) @@ -345,10 +328,10 @@ export const AddFilter: React.FC = ( - {options.map((c) => ( + {thisOptions.map((c) => ( @@ -358,25 +341,12 @@ export const AddFilter: React.FC = ( ); } - const title = !props.editingCriterion + const title = !editingCriterion ? intl.formatMessage({ id: "search_filter.add_filter" }) : intl.formatMessage({ id: "search_filter.update_filter" }); return ( <> - - - - } - > - - - - onToggle()}> + onCancel()}> {title}
@@ -388,7 +358,7 @@ export const AddFilter: React.FC = ( diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx new file mode 100644 index 000000000..9d7ae23f3 --- /dev/null +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Badge, Button } from "react-bootstrap"; +import { + Criterion, + CriterionValue, +} from "src/models/list-filter/criteria/criterion"; +import { useIntl } from "react-intl"; +import { Icon } from "../Shared"; + +interface IFilterTagsProps { + criteria: Criterion[]; + onEditCriterion: (c: Criterion) => void; + onRemoveCriterion: (c: Criterion) => void; +} + +export const FilterTags: React.FC = ({ + criteria, + onEditCriterion, + onRemoveCriterion, +}) => { + const intl = useIntl(); + + function onRemoveCriterionTag( + criterion: Criterion, + $event: React.MouseEvent + ) { + if (!criterion) { + return; + } + onRemoveCriterion(criterion); + $event.stopPropagation(); + } + + function onClickCriterionTag(criterion: Criterion) { + onEditCriterion(criterion); + } + + function renderFilterTags() { + return criteria.map((criterion) => ( + onClickCriterionTag(criterion)} + > + {criterion.getLabel(intl)} + + + )); + } + + return ( +
{renderFilterTags()}
+ ); +}; diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index a8127c161..b064c7b21 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,9 +1,8 @@ import _, { debounce } from "lodash"; -import React, { useState, useEffect } from "react"; +import React, { HTMLAttributes, useEffect } from "react"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; import { - Badge, Button, ButtonGroup, Dropdown, @@ -12,61 +11,44 @@ import { Tooltip, InputGroup, FormControl, - ButtonToolbar, } from "react-bootstrap"; import { Icon } from "src/components/Shared"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { DisplayMode } from "src/models/list-filter/types"; import { useFocus } from "src/utils"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { FormattedMessage, useIntl } from "react-intl"; -import { - Criterion, - CriterionValue, -} from "src/models/list-filter/criteria/criterion"; -import { AddFilter } from "./AddFilter"; - -interface IListFilterOperation { - text: string; - onClick: () => void; - isDisplayed?: () => boolean; -} +import { PersistanceLevel } from "src/hooks/ListHook"; +import { SavedFilterList } from "./SavedFilterList"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; - zoomIndex?: number; - onChangeZoom?: (zoomIndex: number) => void; - onSelectAll?: () => void; - onSelectNone?: () => void; - onEdit?: () => void; - onDelete?: () => void; - otherOperations?: IListFilterOperation[]; filter: ListFilterModel; filterOptions: ListFilterOptions; - itemsSelected?: boolean; + filterDialogOpen?: boolean; + persistState?: PersistanceLevel; + openFilterDialog: () => void; } const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; -const minZoom = 0; -const maxZoom = 3; -export const ListFilter: React.FC = ( - props: IListFilterProps -) => { +export const ListFilter: React.FC = ({ + onFilterUpdate, + filter, + filterOptions, + filterDialogOpen, + openFilterDialog, + persistState, +}) => { const [queryRef, setQueryFocus] = useFocus(); const searchCallback = debounce((value: string) => { - const newFilter = _.cloneDeep(props.filter); + const newFilter = _.cloneDeep(filter); newFilter.searchTerm = value; newFilter.currentPage = 1; - props.onFilterUpdate(newFilter); + onFilterUpdate(newFilter); }, 500); - const [editingCriterion, setEditingCriterion] = useState< - Criterion | undefined - >(undefined); - const intl = useIntl(); useEffect(() => { @@ -76,81 +58,20 @@ export const ListFilter: React.FC = ( }); Mousetrap.bind("r", () => onReshuffleRandomSort()); - Mousetrap.bind("v g", () => { - if (props.filterOptions.displayModeOptions.includes(DisplayMode.Grid)) { - onChangeDisplayMode(DisplayMode.Grid); - } - }); - Mousetrap.bind("v l", () => { - if (props.filterOptions.displayModeOptions.includes(DisplayMode.List)) { - onChangeDisplayMode(DisplayMode.List); - } - }); - Mousetrap.bind("v w", () => { - if (props.filterOptions.displayModeOptions.includes(DisplayMode.Wall)) { - onChangeDisplayMode(DisplayMode.Wall); - } - }); - Mousetrap.bind("+", () => { - if ( - props.onChangeZoom && - props.zoomIndex !== undefined && - props.zoomIndex < maxZoom - ) { - props.onChangeZoom(props.zoomIndex + 1); - } - }); - Mousetrap.bind("-", () => { - if ( - props.onChangeZoom && - props.zoomIndex !== undefined && - props.zoomIndex > minZoom - ) { - props.onChangeZoom(props.zoomIndex - 1); - } - }); - Mousetrap.bind("s a", () => onSelectAll()); - Mousetrap.bind("s n", () => onSelectNone()); - - if (props.itemsSelected) { - Mousetrap.bind("e", () => { - if (props.onEdit) { - props.onEdit(); - } - }); - - Mousetrap.bind("d d", () => { - if (props.onDelete) { - props.onDelete(); - } - }); - } return () => { Mousetrap.unbind("/"); Mousetrap.unbind("r"); - Mousetrap.unbind("v g"); - Mousetrap.unbind("v l"); - Mousetrap.unbind("v w"); - Mousetrap.unbind("+"); - Mousetrap.unbind("-"); - Mousetrap.unbind("s a"); - Mousetrap.unbind("s n"); - - if (props.itemsSelected) { - Mousetrap.unbind("e"); - Mousetrap.unbind("d d"); - } }; }); function onChangePageSize(event: React.ChangeEvent) { const val = event.currentTarget.value; - const newFilter = _.cloneDeep(props.filter); + const newFilter = _.cloneDeep(filter); newFilter.itemsPerPage = parseInt(val, 10); newFilter.currentPage = 1; - props.onFilterUpdate(newFilter); + onFilterUpdate(newFilter); } function onChangeQuery(event: React.FormEvent) { @@ -158,95 +79,32 @@ export const ListFilter: React.FC = ( } function onChangeSortDirection() { - const newFilter = _.cloneDeep(props.filter); - if (props.filter.sortDirection === SortDirectionEnum.Asc) { + const newFilter = _.cloneDeep(filter); + if (filter.sortDirection === SortDirectionEnum.Asc) { newFilter.sortDirection = SortDirectionEnum.Desc; } else { newFilter.sortDirection = SortDirectionEnum.Asc; } - props.onFilterUpdate(newFilter); + onFilterUpdate(newFilter); } function onChangeSortBy(eventKey: string | null) { - const newFilter = _.cloneDeep(props.filter); + const newFilter = _.cloneDeep(filter); newFilter.sortBy = eventKey ?? undefined; newFilter.currentPage = 1; - props.onFilterUpdate(newFilter); + onFilterUpdate(newFilter); } function onReshuffleRandomSort() { - const newFilter = _.cloneDeep(props.filter); + const newFilter = _.cloneDeep(filter); newFilter.currentPage = 1; newFilter.randomSeed = -1; - props.onFilterUpdate(newFilter); - } - - function onChangeDisplayMode(displayMode: DisplayMode) { - const newFilter = _.cloneDeep(props.filter); - newFilter.displayMode = displayMode; - props.onFilterUpdate(newFilter); - } - - function onAddCriterion( - criterion: Criterion, - oldId?: string - ) { - const newFilter = _.cloneDeep(props.filter); - - // Find if we are editing an existing criteria, then modify that. Or create a new one. - const existingIndex = newFilter.criteria.findIndex((c) => { - // If we modified an existing criterion, then look for the old id. - const id = oldId || criterion.getId(); - return c.getId() === id; - }); - if (existingIndex === -1) { - newFilter.criteria.push(criterion); - } else { - newFilter.criteria[existingIndex] = criterion; - } - - // Remove duplicate modifiers - newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => { - return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos; - }); - - newFilter.currentPage = 1; - props.onFilterUpdate(newFilter); - } - - function onCancelAddCriterion() { - setEditingCriterion(undefined); - } - - function onRemoveCriterion(removedCriterion: Criterion) { - const newFilter = _.cloneDeep(props.filter); - newFilter.criteria = newFilter.criteria.filter( - (criterion) => criterion.getId() !== removedCriterion.getId() - ); - newFilter.currentPage = 1; - props.onFilterUpdate(newFilter); - } - - let removedCriterionId = ""; - function onRemoveCriterionTag(criterion?: Criterion) { - if (!criterion) { - return; - } - setEditingCriterion(undefined); - removedCriterionId = criterion.getId(); - onRemoveCriterion(criterion); - } - - function onClickCriterionTag(criterion?: Criterion) { - if (!criterion || removedCriterionId !== "") { - return; - } - setEditingCriterion(criterion); + onFilterUpdate(newFilter); } function renderSortByOptions() { - return props.filterOptions.sortByOptions + return filterOptions.sortByOptions .map((o) => { return { message: intl.formatMessage({ id: o.messageID }), @@ -266,323 +124,134 @@ export const ListFilter: React.FC = ( )); } - function renderDisplayModeOptions() { - function getIcon(option: DisplayMode) { - switch (option) { - case DisplayMode.Grid: - return "th-large"; - case DisplayMode.List: - return "list"; - case DisplayMode.Wall: - return "square"; - case DisplayMode.Tagger: - return "tags"; - } - } - function getLabel(option: DisplayMode) { - let displayModeId = "unknown"; - switch (option) { - case DisplayMode.Grid: - displayModeId = "grid"; - break; - case DisplayMode.List: - displayModeId = "list"; - break; - case DisplayMode.Wall: - displayModeId = "wall"; - break; - case DisplayMode.Tagger: - displayModeId = "tagger"; - break; - } - return intl.formatMessage({ id: `display_mode.${displayModeId}` }); - } - - return props.filterOptions.displayModeOptions.map((option) => ( - {getLabel(option)} - } - > - - - )); - } - - function renderFilterTags() { - return props.filter.criteria.map((criterion) => ( - onClickCriterionTag(criterion)} - > - {criterion.getLabel(intl)} - - - )); - } - - function onSelectAll() { - if (props.onSelectAll) { - props.onSelectAll(); - } - } - - function onSelectNone() { - if (props.onSelectNone) { - props.onSelectNone(); - } - } - - function onEdit() { - if (props.onEdit) { - props.onEdit(); - } - } - - function onDelete() { - if (props.onDelete) { - props.onDelete(); - } - } - - function renderSelectAll() { - if (props.onSelectAll) { - return ( - onSelectAll()} - > - - - ); - } - } - - function renderSelectNone() { - if (props.onSelectNone) { - return ( - onSelectNone()} - > - - - ); - } - } - - function renderMore() { - const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); - - if (props.otherOperations) { - props.otherOperations - .filter((o) => { - if (!o.isDisplayed) { - return true; - } - - return o.isDisplayed(); - }) - .forEach((o) => { - options.push( - - {o.text} - - ); - }); - } - - if (options.length > 0) { - return ( - - - - - - {options} - - - ); - } - } - - function onChangeZoom(v: number) { - if (props.onChangeZoom) { - props.onChangeZoom(v); - } - } - - function maybeRenderZoom() { - if (props.onChangeZoom && props.filter.displayMode === DisplayMode.Grid) { - return ( -
- ) => - onChangeZoom(Number.parseInt(e.currentTarget.value, 10)) - } - /> -
- ); - } - } - - function maybeRenderSelectedButtons() { - if (props.itemsSelected && (props.onEdit || props.onDelete)) { - return ( - - {props.onEdit && ( - - {intl.formatMessage({ id: "actions.edit" })} - - } - > - - - )} - - {props.onDelete && ( - - {intl.formatMessage({ id: "actions.delete" })} - - } - > - - - )} - - ); - } - } + const SavedFilterDropdown = React.forwardRef< + HTMLDivElement, + HTMLAttributes + >(({ style, className }, ref) => ( +
+ { + onFilterUpdate(f); + }} + persistState={persistState} + /> +
+ )); function render() { - const currentSortBy = props.filterOptions.sortByOptions.find( - (o) => o.value === props.filter.sortBy + const currentSortBy = filterOptions.sortByOptions.find( + (o) => o.value === filter.sortBy ); return ( <> - -
- - - - - - - - - - - {currentSortBy - ? intl.formatMessage({ id: currentSortBy.messageID }) - : ""} - - - {renderSortByOptions()} - - - {props.filter.sortDirection === SortDirectionEnum.Asc - ? intl.formatMessage({ id: "ascending" }) - : intl.formatMessage({ id: "descending" })} - - } - > - - - {props.filter.sortBy === "random" && ( +
+ + + - {intl.formatMessage({ id: "actions.reshuffle" })} + + } > - + + + - )} - -
+ +
+ + - - {PAGE_SIZE_OPTIONS.map((s) => ( - - ))} - - - {maybeRenderSelectedButtons()} - -
{renderMore()}
- - {renderDisplayModeOptions()} - {maybeRenderZoom()} - - -
- {renderFilterTags()} + + + + + } + > + + + +
+ + + + {currentSortBy + ? intl.formatMessage({ id: currentSortBy.messageID }) + : ""} + + + {renderSortByOptions()} + + + {filter.sortDirection === SortDirectionEnum.Asc + ? intl.formatMessage({ id: "ascending" }) + : intl.formatMessage({ id: "descending" })} + + } + > + + + {filter.sortBy === "random" && ( + + {intl.formatMessage({ id: "actions.reshuffle" })} + + } + > + + + )} + + + + {PAGE_SIZE_OPTIONS.map((s) => ( + + ))} + ); } diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx new file mode 100644 index 000000000..f93246d6c --- /dev/null +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -0,0 +1,173 @@ +import React, { useEffect } from "react"; +import { + Button, + ButtonGroup, + Dropdown, + OverlayTrigger, + Tooltip, +} from "react-bootstrap"; +import Mousetrap from "mousetrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "../Shared"; + +interface IListFilterOperation { + text: string; + onClick: () => void; + isDisplayed?: () => boolean; +} + +interface IListOperationButtonsProps { + onSelectAll?: () => void; + onSelectNone?: () => void; + onEdit?: () => void; + onDelete?: () => void; + itemsSelected?: boolean; + otherOperations?: IListFilterOperation[]; +} + +export const ListOperationButtons: React.FC = ({ + onSelectAll, + onSelectNone, + onEdit, + onDelete, + itemsSelected, + otherOperations, +}) => { + const intl = useIntl(); + + useEffect(() => { + Mousetrap.bind("s a", () => onSelectAll?.()); + Mousetrap.bind("s n", () => onSelectNone?.()); + + if (itemsSelected) { + Mousetrap.bind("e", () => { + onEdit?.(); + }); + + Mousetrap.bind("d d", () => { + onDelete?.(); + }); + } + + return () => { + Mousetrap.unbind("s a"); + Mousetrap.unbind("s n"); + + if (itemsSelected) { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + } + }; + }); + + function maybeRenderSelectedButtons() { + if (itemsSelected && (onEdit || onDelete)) { + return ( + + {onEdit && ( + + {intl.formatMessage({ id: "actions.edit" })} + + } + > + + + )} + + {onDelete && ( + + {intl.formatMessage({ id: "actions.delete" })} + + } + > + + + )} + + ); + } + } + + function renderSelectAll() { + if (onSelectAll) { + return ( + onSelectAll?.()} + > + + + ); + } + } + + function renderSelectNone() { + if (onSelectNone) { + return ( + onSelectNone?.()} + > + + + ); + } + } + + function renderMore() { + const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); + + if (otherOperations) { + otherOperations + .filter((o) => { + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }) + .forEach((o) => { + options.push( + + {o.text} + + ); + }); + } + + if (options.length > 0) { + return ( + + + + + + {options} + + + ); + } + } + + return ( + <> + {maybeRenderSelectedButtons()} + +
{renderMore()}
+ + ); +}; diff --git a/ui/v2.5/src/components/List/ListViewOptions.tsx b/ui/v2.5/src/components/List/ListViewOptions.tsx new file mode 100644 index 000000000..8cf35673c --- /dev/null +++ b/ui/v2.5/src/components/List/ListViewOptions.tsx @@ -0,0 +1,159 @@ +import React, { useEffect } from "react"; +import Mousetrap from "mousetrap"; +import { + Button, + ButtonGroup, + Form, + OverlayTrigger, + Tooltip, +} from "react-bootstrap"; +import { DisplayMode } from "src/models/list-filter/types"; +import { useIntl } from "react-intl"; +import { Icon } from "../Shared"; + +interface IListViewOptionsProps { + zoomIndex?: number; + onSetZoom?: (zoomIndex: number) => void; + displayMode: DisplayMode; + onSetDisplayMode: (m: DisplayMode) => void; + displayModeOptions: DisplayMode[]; +} + +export const ListViewOptions: React.FC = ({ + zoomIndex, + onSetZoom, + displayMode, + onSetDisplayMode, + displayModeOptions, +}) => { + const minZoom = 0; + const maxZoom = 3; + + const intl = useIntl(); + + useEffect(() => { + Mousetrap.bind("v g", () => { + if (displayModeOptions.includes(DisplayMode.Grid)) { + onSetDisplayMode(DisplayMode.Grid); + } + }); + Mousetrap.bind("v l", () => { + if (displayModeOptions.includes(DisplayMode.List)) { + onSetDisplayMode(DisplayMode.List); + } + }); + Mousetrap.bind("v w", () => { + if (displayModeOptions.includes(DisplayMode.Wall)) { + onSetDisplayMode(DisplayMode.Wall); + } + }); + Mousetrap.bind("+", () => { + if (onSetZoom && zoomIndex !== undefined && zoomIndex < maxZoom) { + onSetZoom(zoomIndex + 1); + } + }); + Mousetrap.bind("-", () => { + if (onSetZoom && zoomIndex !== undefined && zoomIndex > minZoom) { + onSetZoom(zoomIndex - 1); + } + }); + + return () => { + Mousetrap.unbind("v g"); + Mousetrap.unbind("v l"); + Mousetrap.unbind("v w"); + Mousetrap.unbind("+"); + Mousetrap.unbind("-"); + }; + }); + + function maybeRenderDisplayModeOptions() { + function getIcon(option: DisplayMode) { + switch (option) { + case DisplayMode.Grid: + return "th-large"; + case DisplayMode.List: + return "list"; + case DisplayMode.Wall: + return "square"; + case DisplayMode.Tagger: + return "tags"; + } + } + function getLabel(option: DisplayMode) { + let displayModeId = "unknown"; + switch (option) { + case DisplayMode.Grid: + displayModeId = "grid"; + break; + case DisplayMode.List: + displayModeId = "list"; + break; + case DisplayMode.Wall: + displayModeId = "wall"; + break; + case DisplayMode.Tagger: + displayModeId = "tagger"; + break; + } + return intl.formatMessage({ id: `display_mode.${displayModeId}` }); + } + + if (displayModeOptions.length < 2) { + return; + } + + return ( + + {displayModeOptions.map((option) => ( + {getLabel(option)} + } + > + + + ))} + + ); + } + + function onChangeZoom(v: number) { + if (onSetZoom) { + onSetZoom(v); + } + } + + function maybeRenderZoom() { + if (onSetZoom && displayMode === DisplayMode.Grid) { + return ( +
+ ) => + onChangeZoom(Number.parseInt(e.currentTarget.value, 10)) + } + /> +
+ ); + } + } + + return ( + <> + {maybeRenderDisplayModeOptions()} + {maybeRenderZoom()} + + ); +}; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx new file mode 100644 index 000000000..68a5d7d0e --- /dev/null +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Button, + ButtonGroup, + Dropdown, + FormControl, + InputGroup, + Modal, + OverlayTrigger, + Tooltip, +} from "react-bootstrap"; +import { + useFindSavedFilters, + useSavedFilterDestroy, + useSaveFilter, + useSetDefaultFilter, +} from "src/core/StashService"; +import { useToast } from "src/hooks"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { SavedFilterDataFragment } from "src/core/generated-graphql"; +import { LoadingIndicator } from "src/components/Shared"; +import { PersistanceLevel } from "src/hooks/ListHook"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "../Shared"; + +interface ISavedFilterListProps { + filter: ListFilterModel; + onSetFilter: (f: ListFilterModel) => void; + persistState?: PersistanceLevel; +} + +export const SavedFilterList: React.FC = ({ + filter, + onSetFilter, + persistState, +}) => { + const Toast = useToast(); + const intl = useIntl(); + + const { data, error, loading, refetch } = useFindSavedFilters(filter.mode); + const oldError = useRef(error); + + const [filterName, setFilterName] = useState(""); + const [saving, setSaving] = useState(false); + const [deletingFilter, setDeletingFilter] = useState< + SavedFilterDataFragment | undefined + >(); + const [overwritingFilter, setOverwritingFilter] = useState< + SavedFilterDataFragment | undefined + >(); + + const [saveFilter] = useSaveFilter(); + const [destroyFilter] = useSavedFilterDestroy(); + const [setDefaultFilter] = useSetDefaultFilter(); + + const savedFilters = data?.findSavedFilters ?? []; + + useEffect(() => { + if (error && error !== oldError.current) { + Toast.error(error); + } + + oldError.current = error; + }, [error, Toast, oldError]); + + async function onSaveFilter(name: string, id?: string) { + const filterCopy = filter.clone(); + filterCopy.currentPage = 1; + + try { + setSaving(true); + await saveFilter({ + variables: { + input: { + id, + mode: filter.mode, + name, + filter: JSON.stringify(filterCopy.getSavedQueryParameters()), + }, + }, + }); + + Toast.success({ + content: intl.formatMessage( + { + id: "toast.saved_entity", + }, + { + entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(), + } + ), + }); + setFilterName(""); + setOverwritingFilter(undefined); + refetch(); + } catch (err) { + Toast.error(err); + } finally { + setSaving(false); + } + } + + async function onDeleteFilter(f: SavedFilterDataFragment) { + try { + setSaving(true); + + await destroyFilter({ + variables: { + input: { + id: f.id, + }, + }, + }); + + Toast.success({ + content: intl.formatMessage( + { + id: "toast.delete_past_tense", + }, + { + count: 1, + singularEntity: intl.formatMessage({ id: "filter" }), + pluralEntity: intl.formatMessage({ id: "filters" }), + } + ), + }); + refetch(); + } catch (err) { + Toast.error(err); + } finally { + setSaving(false); + setDeletingFilter(undefined); + } + } + + async function onSetDefaultFilter() { + const filterCopy = filter.clone(); + filterCopy.currentPage = 1; + + try { + setSaving(true); + + await setDefaultFilter({ + variables: { + input: { + mode: filter.mode, + filter: JSON.stringify(filterCopy.getSavedQueryParameters()), + }, + }, + }); + + Toast.success({ + content: intl.formatMessage({ + id: "toast.default_filter_set", + }), + }); + } catch (err) { + Toast.error(err); + } finally { + setSaving(false); + } + } + + function filterClicked(f: SavedFilterDataFragment) { + const newFilter = filter.clone(); + newFilter.currentPage = 1; + newFilter.configureFromQueryParameters(JSON.parse(f.filter)); + + onSetFilter(newFilter); + } + + interface ISavedFilterItem { + item: SavedFilterDataFragment; + } + const SavedFilterItem: React.FC = ({ item }) => { + return ( +
+ filterClicked(item)} title={item.name}> + {item.name} + + + + + +
+ ); + }; + + function maybeRenderDeleteAlert() { + if (!deletingFilter) { + return; + } + + return ( + + + + + + + + + + ); + } + + function maybeRenderOverwriteAlert() { + if (!overwritingFilter) { + return; + } + + return ( + + + + + + + + + + ); + } + + function renderSavedFilters() { + if (loading || saving) { + return ( +
+ +
+ ); + } + + return ( +
    + {savedFilters + .filter( + (f) => !filterName || f.name.toLowerCase().includes(filterName) + ) + .map((f) => ( + + ))} +
+ ); + } + + function maybeRenderSetDefaultButton() { + if (persistState === PersistanceLevel.ALL) { + return ( + + ); + } + } + + return ( +
+ {maybeRenderDeleteAlert()} + {maybeRenderOverwriteAlert()} + + setFilterName(e.target.value)} + /> + + + + + } + > + + + + + {renderSavedFilters()} + {maybeRenderSetDefaultButton()} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 6cdea51aa..96e62392a 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -24,3 +24,54 @@ input[type="range"].zoom-slider { padding-left: 0; padding-right: 0; } + +.query-text-field { + border: 0; + width: 50%; +} + +.saved-filter-list-menu { + width: 300px; + + .set-as-default-button { + float: right; + } + + .LoadingIndicator { + height: auto; + text-align: center; + + .spinner-border { + height: 1.5rem; + width: 1.5rem; + } + } +} + +.saved-filter-list { + list-style: none; + margin-bottom: 0; + max-height: 230px; + overflow-y: auto; + padding-left: 0; + + .dropdown-item-container { + display: flex; + + .dropdown-item { + align-items: center; + display: inline; + overflow-x: hidden; + padding-right: 0.25rem; + text-overflow: ellipsis; + } + + .btn-group { + margin-left: auto; + } + + .delete-button { + color: $danger; + } + } +} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index cfc65d817..873007fdb 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -13,7 +13,7 @@ export const MovieScenesPanel: React.FC = ({ movie }) => { const movieValue = { id: movie.id!, label: movie.name! }; // if movie is already present, then we modify it, otherwise add let movieCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "movies"; + return c.criterionOption.type === "movies"; }) as MoviesCriterion; if ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 3247a3749..eaeb024bd 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -2,10 +2,10 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { TagLink } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; -import { genderToString } from "src/core/StashService"; import { TextUtils } from "src/utils"; import { TextField, URLField } from "src/utils/field"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { genderToString } from "src/utils/gender"; interface IPerformerDetails { performer: Partial; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index eb9c12b3d..b89cbbd89 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -14,10 +14,7 @@ import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { - getGenderStrings, useListPerformerScrapers, - genderToString, - stringToGender, queryScrapePerformer, mutateReloadScrapers, usePerformerUpdate, @@ -40,6 +37,11 @@ import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { + genderStrings, + genderToString, + stringToGender, +} from "src/utils/gender"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; @@ -92,7 +94,7 @@ export const PerformerEditPanel: React.FC = ({ const [createTag] = useTagCreate(); - const genderOptions = [""].concat(getGenderStrings()); + const genderOptions = [""].concat(genderStrings); const labelXS = 3; const labelXL = 2; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 5ee7f1ccf..0f6d0c9c6 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -9,23 +9,23 @@ import { ScrapeDialogRow, ScrapedTextAreaRow, } from "src/components/Shared/ScrapeDialog"; -import { - getGenderStrings, - genderToString, - stringToGender, - useTagCreate, -} from "src/core/StashService"; +import { useTagCreate } from "src/core/StashService"; import { Form } from "react-bootstrap"; import { TagSelect } from "src/components/Shared"; import { useToast } from "src/hooks"; import _ from "lodash"; +import { + genderStrings, + genderToString, + stringToGender, +} from "src/utils/gender"; function renderScrapedGender( result: ScrapeResult, isNew?: boolean, onChange?: (value: string) => void ) { - const selectOptions = [""].concat(getGenderStrings()); + const selectOptions = [""].concat(genderStrings); return ( { return; } - const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); + const filterCopy = sceneQueue.query.clone(); const newStart = queueStart - filterCopy.itemsPerPage; filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); const query = await queryFindScenes(filterCopy); @@ -254,7 +254,7 @@ export const Scene: React.FC = () => { return; } - const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); + const filterCopy = sceneQueue.query.clone(); const newStart = queueStart + queueScenes.length; filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); const query = await queryFindScenes(filterCopy); @@ -291,7 +291,7 @@ export const Scene: React.FC = () => { const pages = Math.ceil(queueTotal / query.itemsPerPage); const page = Math.floor(Math.random() * pages) + 1; const index = Math.floor(Math.random() * query.itemsPerPage); - const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); + const filterCopy = sceneQueue.query.clone(); filterCopy.currentPage = page; const queryResults = await queryFindScenes(filterCopy); if (queryResults.data.findScenes.scenes.length > index) { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index 9eac754d2..a8ef45547 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -15,7 +15,7 @@ export const StudioChildrenPanel: React.FC = ({ const studioValue = { id: studio.id!, label: studio.name! }; // if studio is already present, then we modify it, otherwise add let parentStudioCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "parent_studios"; + return c.criterionOption.type === "parent_studios"; }) as ParentStudiosCriterion; if ( diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index edd32cfe1..913cb94d9 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -11,8 +11,8 @@ import { TruncatedText, } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; -import { genderToString } from "src/core/StashService"; import { TextUtils } from "src/utils"; +import { genderToString } from "src/utils/gender"; import { IStashBoxPerformer } from "./utils"; interface IPerformerModalProps { diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index a8de8676c..b520b5905 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -16,7 +16,7 @@ export const TagMarkersPanel: React.FC = ({ tag }) => { const tagValue = { id: tag.id!, label: tag.name! }; // if tag is already present, then we modify it, otherwise add let tagCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "tags"; + return c.criterionOption.type === "tags"; }) as TagsCriterion; if ( diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 9325cc21e..b0adfdbc8 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -5,6 +5,7 @@ import { getQueryDefinition, getOperationName, } from "@apollo/client/utilities"; +import { stringToGender } from "src/utils/gender"; import { filterData } from "../utils"; import { ListFilterModel } from "../models/list-filter/filter"; import * as GQL from "./generated-graphql"; @@ -43,6 +44,20 @@ const deleteCache = (queries: DocumentNode[]) => { }); }; +export const useFindSavedFilters = (mode: GQL.FilterMode) => + GQL.useFindSavedFiltersQuery({ + variables: { + mode, + }, + }); + +export const useFindDefaultFilter = (mode: GQL.FilterMode) => + GQL.useFindDefaultFilterQuery({ + variables: { + mode, + }, + }); + export const useFindGalleries = (filter: ListFilterModel) => GQL.useFindGalleriesQuery({ variables: { @@ -680,6 +695,29 @@ export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) => update: deleteCache(tagMutationImpactedQueries), }); +export const savedFilterMutationImpactedQueries = [ + GQL.FindSavedFiltersDocument, +]; + +export const useSaveFilter = () => + GQL.useSaveFilterMutation({ + update: deleteCache(savedFilterMutationImpactedQueries), + }); + +export const savedFilterDefaultMutationImpactedQueries = [ + GQL.FindDefaultFilterDocument, +]; + +export const useSetDefaultFilter = () => + GQL.useSetDefaultFilterMutation({ + update: deleteCache(savedFilterDefaultMutationImpactedQueries), + }); + +export const useSavedFilterDestroy = () => + GQL.useDestroySavedFilterMutation({ + update: deleteCache(savedFilterMutationImpactedQueries), + }); + export const useTagsMerge = () => GQL.useTagsMergeMutation({ update: deleteCache(tagMutationImpactedQueries), @@ -973,54 +1011,6 @@ export const queryParseSceneFilenames = ( fetchPolicy: "network-only", }); -export const stringGenderMap = new Map([ - ["Male", GQL.GenderEnum.Male], - ["Female", GQL.GenderEnum.Female], - ["Transgender Male", GQL.GenderEnum.TransgenderMale], - ["Transgender Female", GQL.GenderEnum.TransgenderFemale], - ["Intersex", GQL.GenderEnum.Intersex], - ["Non-Binary", GQL.GenderEnum.NonBinary], -]); - -export const genderToString = (value?: GQL.GenderEnum | string) => { - if (!value) { - return undefined; - } - - const foundEntry = Array.from(stringGenderMap.entries()).find((e) => { - return e[1] === value; - }); - - if (foundEntry) { - return foundEntry[0]; - } -}; - -export const stringToGender = ( - value?: string | null, - caseInsensitive?: boolean -) => { - if (!value) { - return undefined; - } - - const ret = stringGenderMap.get(value); - if (ret || !caseInsensitive) { - return ret; - } - - const asUpper = value.toUpperCase(); - const foundEntry = Array.from(stringGenderMap.entries()).find((e) => { - return e[0].toUpperCase() === asUpper; - }); - - if (foundEntry) { - return foundEntry[1]; - } -}; - -export const getGenderStrings = () => Array.from(stringGenderMap.keys()); - export const makePerformerCreateInput = ( toCreate: GQL.ScrapedScenePerformer ) => { diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 2d3478aef..8f745d97d 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -9,7 +9,7 @@ export const performerFilterHook = ( const performerValue = { id: performer.id!, label: performer.name! }; // if performers is already present, then we modify it, otherwise add let performerCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "performers"; + return c.criterionOption.type === "performers"; }) as PerformersCriterion; if ( diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index 48d7b1b2a..f967987aa 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -7,7 +7,7 @@ export const studioFilterHook = (studio: Partial) => { const studioValue = { id: studio.id!, label: studio.name! }; // if studio is already present, then we modify it, otherwise add let studioCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "studios"; + return c.criterionOption.type === "studios"; }) as StudiosCriterion; if ( diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 74b7669bd..0c0afed61 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -10,7 +10,7 @@ export const tagFilterHook = (tag: GQL.TagDataFragment) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add let tagCriterion = filter.criteria.find((c) => { - return c.criterionOption.value === "tags"; + return c.criterionOption.type === "tags"; }) as TagsCriterion; if ( diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 03e74ce16..e5dc492f6 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -27,12 +27,15 @@ import { TagDataFragment, FindImagesQueryResult, SlimImageDataFragment, + FilterMode, } from "src/core/generated-graphql"; import { useInterfaceLocalForage } from "src/hooks/LocalForage"; import { LoadingIndicator } from "src/components/Shared"; import { ListFilter } from "src/components/List/ListFilter"; +import { FilterTags } from "src/components/List/FilterTags"; import { Pagination, PaginationIndex } from "src/components/List/Pagination"; import { + useFindDefaultFilter, useFindScenes, useFindSceneMarkers, useFindImages, @@ -43,9 +46,17 @@ import { useFindTags, } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { FilterMode } from "src/models/list-filter/types"; +import { DisplayMode } from "src/models/list-filter/types"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { getFilterOptions } from "src/models/list-filter/factory"; +import { ButtonToolbar } from "react-bootstrap"; +import { ListViewOptions } from "src/components/List/ListViewOptions"; +import { ListOperationButtons } from "src/components/List/ListOperationButtons"; +import { + Criterion, + CriterionValue, +} from "src/models/list-filter/criteria/criterion"; +import { AddFilterDialog } from "src/components/List/AddFilterDialog"; const getSelectedData = ( result: I[], @@ -88,8 +99,11 @@ export interface IListHookOperation { } export enum PersistanceLevel { + // do not load default query or persist display mode NONE, + // load default query, don't load or persist display mode ALL, + // load and persist display mode only VIEW, } @@ -98,6 +112,10 @@ interface IListHookOptions { persistanceKey?: string; defaultSort?: string; filterHook?: (filter: ListFilterModel) => ListFilterModel; + filterDialog?: ( + criteria: Criterion[], + setCriteria: (v: Criterion[]) => void + ) => React.ReactNode; zoomable?: boolean; selectable?: boolean; defaultZoomIndex?: number; @@ -167,6 +185,8 @@ const RenderList = < renderEditDialog, renderDeleteDialog, updateQueryParams, + filterDialog, + persistState, }: IListHookOptions & IQuery & IRenderListProps) => { @@ -176,6 +196,11 @@ const RenderList = < const [lastClickedId, setLastClickedId] = useState(); const [zoomIndex, setZoomIndex] = useState(defaultZoomIndex ?? 1); + const [editingCriterion, setEditingCriterion] = useState< + Criterion | undefined + >(undefined); + const [newCriterion, setNewCriterion] = useState(false); + const result = useData(filter); const totalCount = getCount(result); const items = getData(result); @@ -189,6 +214,7 @@ const RenderList = < }, [pages, filter.currentPage, onChangePage]); useEffect(() => { + Mousetrap.bind("f", () => setNewCriterion(true)); Mousetrap.bind("right", () => { const maxPage = totalCount / filter.itemsPerPage; if (filter.currentPage < maxPage) { @@ -397,21 +423,104 @@ const RenderList = < ); } + function onChangeDisplayMode(displayMode: DisplayMode) { + const newFilter = _.cloneDeep(filter); + newFilter.displayMode = displayMode; + updateQueryParams(newFilter); + } + + function onAddCriterion( + criterion: Criterion, + oldId?: string + ) { + const newFilter = _.cloneDeep(filter); + + // Find if we are editing an existing criteria, then modify that. Or create a new one. + const existingIndex = newFilter.criteria.findIndex((c) => { + // If we modified an existing criterion, then look for the old id. + const id = oldId || criterion.getId(); + return c.getId() === id; + }); + if (existingIndex === -1) { + newFilter.criteria.push(criterion); + } else { + newFilter.criteria[existingIndex] = criterion; + } + + // Remove duplicate modifiers + newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => { + return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos; + }); + + newFilter.currentPage = 1; + updateQueryParams(newFilter); + setEditingCriterion(undefined); + setNewCriterion(false); + } + + function onRemoveCriterion(removedCriterion: Criterion) { + const newFilter = _.cloneDeep(filter); + newFilter.criteria = newFilter.criteria.filter( + (criterion) => criterion.getId() !== removedCriterion.getId() + ); + newFilter.currentPage = 1; + updateQueryParams(newFilter); + } + + function updateCriteria(c: Criterion[]) { + const newFilter = _.cloneDeep(filter); + newFilter.criteria = c.slice(); + setNewCriterion(false); + } + + function onCancelAddCriterion() { + setEditingCriterion(undefined); + setNewCriterion(false); + } + const content = (
- 0} - onEdit={renderEditDialog ? onEdit : undefined} - onDelete={renderDeleteDialog ? onDelete : undefined} - filter={filter} - filterOptions={filterOptions} + + setNewCriterion(true)} + filterDialogOpen={newCriterion ?? editingCriterion} + persistState={persistState} + /> + 0} + onEdit={renderEditDialog ? onEdit : undefined} + onDelete={renderDeleteDialog ? onDelete : undefined} + /> + + + setEditingCriterion(c)} + onRemoveCriterion={onRemoveCriterion} /> + {(newCriterion || editingCriterion) && !filterDialog && ( + + )} + {newCriterion && + filterDialog && + filterDialog(filter.criteria, (c) => updateCriteria(c))} {isEditDialogOpen && renderEditDialog && renderEditDialog( @@ -454,6 +563,7 @@ const useList = ( const defaultDisplayMode = filterOptions.displayModeOptions[0]; const [filter, setFilter] = useState( new ListFilterModel( + options.filterMode, queryString.parse(location.search), defaultSort, defaultDisplayMode @@ -462,8 +572,8 @@ const useList = ( const updateInterfaceConfig = useCallback( (updatedFilter: ListFilterModel, level: PersistanceLevel) => { - setInterfaceState((prevState) => { - if (level === PersistanceLevel.VIEW) { + if (level === PersistanceLevel.VIEW) { + setInterfaceState((prevState) => { return { [persistanceKey]: { ...prevState[persistanceKey], @@ -473,84 +583,16 @@ const useList = ( }), }, }; - } - return { - [persistanceKey]: { - filter: updatedFilter.makeQueryParameters(), - itemsPerPage: updatedFilter.itemsPerPage, - currentPage: updatedFilter.currentPage, - }, - }; - }); + }); + } }, [persistanceKey, setInterfaceState] ); - useEffect(() => { - if ( - interfaceState.loading || - // Only update query params on page the hook was mounted on - history.location.pathname !== originalPathName.current - ) - return; - - if (!forageInitialised) setForageInitialised(true); - - if (!options.persistState) return; - - const storedQuery = interfaceState.data?.[persistanceKey]; - if (!storedQuery) return; - - const queryFilter = queryString.parse(history.location.search); - const storedFilter = queryString.parse(storedQuery.filter); - - const activeFilter = - options.persistState === PersistanceLevel.ALL - ? storedFilter - : { disp: storedFilter.disp }; - const query = history.location.search - ? { - sortby: activeFilter.sortby, - sortdir: activeFilter.sortdir, - disp: activeFilter.disp, - perPage: activeFilter.perPage, - ...queryFilter, - } - : activeFilter; - - const newFilter = new ListFilterModel( - query, - defaultSort, - defaultDisplayMode - ); - - // Compare constructed filter with current filter. - // If different it's the result of navigation, and we update the filter. - const newLocation = { ...history.location }; - newLocation.search = newFilter.makeQueryParameters(); - if (newLocation.search !== filter.makeQueryParameters()) { - setFilter(newFilter); - updateInterfaceConfig(newFilter, options.persistState); - } - // If constructed search is different from current, update it as well - if (newLocation.search !== location.search) { - newLocation.search = newFilter.makeQueryParameters(); - history.replace(newLocation); - } - }, [ - defaultSort, - defaultDisplayMode, - filter, - interfaceState.data, - interfaceState.loading, - history, - location.search, - options.filterMode, - persistanceKey, - forageInitialised, - updateInterfaceConfig, - options.persistState, - ]); + const { + data: defaultFilter, + loading: defaultFilterLoading, + } = useFindDefaultFilter(options.filterMode); const updateQueryParams = useCallback( (listFilter: ListFilterModel) => { @@ -565,6 +607,67 @@ const useList = ( [setFilter, history, location, options.persistState, updateInterfaceConfig] ); + useEffect(() => { + if ( + // defer processing this until forage is initialised and + // default filter is loaded + interfaceState.loading || + defaultFilterLoading || + // Only update query params on page the hook was mounted on + history.location.pathname !== originalPathName.current + ) + return; + + if (!forageInitialised) setForageInitialised(true); + + if (!options.persistState) return; + + const newFilter = filter.clone(); + let update = false; + + // if default query is set and no search params are set, then + // load the default query + if (!location.search && defaultFilter?.findDefaultFilter) { + newFilter.currentPage = 1; + newFilter.configureFromQueryParameters( + JSON.parse(defaultFilter.findDefaultFilter.filter) + ); + update = true; + } + + // set the display type if persisted + const storedQuery = interfaceState.data?.[persistanceKey]; + + if (options.persistState === PersistanceLevel.VIEW && storedQuery) { + const storedFilter = queryString.parse(storedQuery.filter); + + if (storedFilter.disp !== undefined) { + const displayMode = Number.parseInt(storedFilter.disp as string, 10); + if (displayMode !== newFilter.displayMode) { + newFilter.displayMode = displayMode; + update = true; + } + } + } + + if (update) { + updateQueryParams(newFilter); + } + }, [ + defaultSort, + defaultDisplayMode, + filter, + interfaceState, + history, + location.search, + updateQueryParams, + defaultFilter, + defaultFilterLoading, + persistanceKey, + forageInitialised, + options.persistState, + ]); + const onChangePage = useCallback( (page: number) => { const newFilter = _.cloneDeep(filter); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6305c8929..a1122bc50 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -58,6 +58,7 @@ "reshuffle": "Reshuffle", "running": "running", "save": "Save", + "save_filter": "Save filter", "scan": "Scan", "scrape_with": "Scrape with…", "search": "Search", @@ -65,6 +66,7 @@ "select_none": "Select None", "selective_auto_tag": "Selective Auto Tag", "selective_scan": "Selective Scan", + "set_as_default": "Set as default", "set_back_image": "Back image…", "set_front_image": "Front image…", "set_image": "Set image…", @@ -400,6 +402,7 @@ "destination": "Destination", "source": "Source" }, + "overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", "scene_gen": { "image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)", "markers": "Markers (20 second videos which begin at the given timecode)", @@ -477,6 +480,9 @@ "file_info": "File Info", "file_mod_time": "File Modification Time", "filesize": "File Size", + "filter": "Filter", + "filter_name": "Filter name", + "filters": "Filters", "framerate": "Frame Rate", "galleries": "Galleries", "gallery": "Gallery", @@ -490,6 +496,7 @@ "image_count": "Image Count", "images": "Images", "images-size": "Images size", + "include_child_studios": "Include child studios", "instagram": "Instagram", "interactive": "Interactive", "isMissing": "Is Missing", @@ -552,6 +559,7 @@ "search_filter": { "add_filter": "Add Filter", "name": "Filter", + "saved_filters": "Saved filters", "update_filter": "Update Filter" }, "seconds": "Seconds", @@ -570,12 +578,15 @@ "toast": { "added_entity": "Added {entity}", "added_generation_job_to_queue": "Added generation job to queue", + "create_entity": "Created {entity}", + "default_filter_set": "Default filter set", "delete_entity": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", "merged_tags": "Merged tags", "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "started_auto_tagging": "Started auto tagging", + "saved_entity": "Saved {entity}", "updated_entity": "Updated {entity}" }, "total": "Total", diff --git a/ui/v2.5/src/models/list-filter/criteria/country.ts b/ui/v2.5/src/models/list-filter/criteria/country.ts index e08e7fa36..b861b0474 100644 --- a/ui/v2.5/src/models/list-filter/criteria/country.ts +++ b/ui/v2.5/src/models/list-filter/criteria/country.ts @@ -1,6 +1,10 @@ -import { CriterionOption, StringCriterion } from "./criterion"; +import { StringCriterion, StringCriterionOption } from "./criterion"; -const countryCriterionOption = new CriterionOption("country", "country"); +const countryCriterionOption = new StringCriterionOption( + "country", + "country", + "country" +); export class CountryCriterion extends StringCriterion { constructor() { 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 ea3eac771..7e1c55788 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -16,106 +16,58 @@ import { IHierarchicalLabelValue, } from "../types"; -type Option = string | number | IOptionType; +export type Option = string | number | IOptionType; export type CriterionValue = | string | number | ILabeledId[] | IHierarchicalLabelValue; +const modifierMessageIDs = { + [CriterionModifier.Equals]: "criterion_modifier.equals", + [CriterionModifier.NotEquals]: "criterion_modifier.not_equals", + [CriterionModifier.GreaterThan]: "criterion_modifier.greater_than", + [CriterionModifier.LessThan]: "criterion_modifier.less_than", + [CriterionModifier.IsNull]: "criterion_modifier.is_null", + [CriterionModifier.NotNull]: "criterion_modifier.not_null", + [CriterionModifier.Includes]: "criterion_modifier.includes", + [CriterionModifier.IncludesAll]: "criterion_modifier.includes_all", + [CriterionModifier.Excludes]: "criterion_modifier.excludes", + [CriterionModifier.MatchesRegex]: "criterion_modifier.matches_regex", + [CriterionModifier.NotMatchesRegex]: "criterion_modifier.not_matches_regex", +}; + // V = criterion value type export abstract class Criterion { public static getModifierOption( modifier: CriterionModifier = CriterionModifier.Equals ): ILabeledValue { - switch (modifier) { - case CriterionModifier.Equals: - return { value: CriterionModifier.Equals, label: "Equals" }; - case CriterionModifier.NotEquals: - return { value: CriterionModifier.NotEquals, label: "Not Equals" }; - case CriterionModifier.GreaterThan: - return { value: CriterionModifier.GreaterThan, label: "Greater Than" }; - case CriterionModifier.LessThan: - return { value: CriterionModifier.LessThan, label: "Less Than" }; - case CriterionModifier.IsNull: - return { value: CriterionModifier.IsNull, label: "Is NULL" }; - case CriterionModifier.NotNull: - return { value: CriterionModifier.NotNull, label: "Not NULL" }; - case CriterionModifier.IncludesAll: - return { value: CriterionModifier.IncludesAll, label: "Includes All" }; - case CriterionModifier.Includes: - return { value: CriterionModifier.Includes, label: "Includes" }; - case CriterionModifier.Excludes: - return { value: CriterionModifier.Excludes, label: "Excludes" }; - case CriterionModifier.MatchesRegex: - return { - value: CriterionModifier.MatchesRegex, - label: "Matches Regex", - }; - case CriterionModifier.NotMatchesRegex: - return { - value: CriterionModifier.NotMatchesRegex, - label: "Not Matches Regex", - }; - } + const messageID = modifierMessageIDs[modifier]; + return { value: modifier, label: messageID }; } public criterionOption: CriterionOption; - public abstract modifier: CriterionModifier; - public abstract modifierOptions: ILabeledValue[]; - public abstract options: Option[] | undefined; - public abstract value: V; - public inputType: "number" | "text" | undefined; + public modifier: CriterionModifier; + public value: V; public abstract getLabelValue(): string; - constructor(type: CriterionOption) { + constructor(type: CriterionOption, value: V) { this.criterionOption = type; + this.modifier = type.defaultModifier; + this.value = value; + } + + public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { + const modifierMessageID = modifierMessageIDs[modifier]; + + return modifierMessageID + ? intl.formatMessage({ id: modifierMessageID }) + : ""; } public getLabel(intl: IntlShape): string { - let modifierMessageID: string; - switch (this.modifier) { - case CriterionModifier.Equals: - modifierMessageID = "criterion_modifier.equals"; - break; - case CriterionModifier.NotEquals: - modifierMessageID = "criterion_modifier.not_equals"; - break; - case CriterionModifier.GreaterThan: - modifierMessageID = "criterion_modifier.greater_than"; - break; - case CriterionModifier.LessThan: - modifierMessageID = "criterion_modifier.less_than"; - break; - case CriterionModifier.IsNull: - modifierMessageID = "criterion_modifier.is_null"; - break; - case CriterionModifier.NotNull: - modifierMessageID = "criterion_modifier.not_null"; - break; - case CriterionModifier.Includes: - modifierMessageID = "criterion_modifier.includes"; - break; - case CriterionModifier.IncludesAll: - modifierMessageID = "criterion_modifier.includes_all"; - break; - case CriterionModifier.Excludes: - modifierMessageID = "criterion_modifier.excludes"; - break; - case CriterionModifier.MatchesRegex: - modifierMessageID = "criterion_modifier.matches_regex"; - break; - case CriterionModifier.NotMatchesRegex: - modifierMessageID = "criterion_modifier.not_matches_regex"; - break; - default: - modifierMessageID = ""; - } - - const modifierString = modifierMessageID - ? intl.formatMessage({ id: modifierMessageID }) - : ""; + const modifierString = Criterion.getModifierLabel(intl, this.modifier); let valueString = ""; if ( @@ -145,7 +97,7 @@ export abstract class Criterion { public toJSON() { const encodedCriterion = { - type: this.criterionOption.value, + type: this.criterionOption.type, // #394 - the presence of a # symbol results in the query URL being // malformed. We could set encode: true in the queryString.stringify // call below, but this results in a URL that gets pretty long and ugly. @@ -171,37 +123,72 @@ export abstract class Criterion { } } +export type InputType = "number" | "text" | undefined; + +interface ICriterionOptionsParams { + messageID: string; + type: CriterionType; + inputType?: InputType; + parameterName?: string; + modifierOptions?: CriterionModifier[]; + defaultModifier?: CriterionModifier; + options?: Option[]; +} export class CriterionOption { public readonly messageID: string; - public readonly value: CriterionType; + public readonly type: CriterionType; public readonly parameterName: string; + public readonly modifierOptions: ILabeledValue[]; + public readonly defaultModifier: CriterionModifier; + public readonly options: Option[] | undefined; + public readonly inputType: InputType; - constructor(messageID: string, value: CriterionType, parameterName?: string) { - this.messageID = messageID; - this.value = value; - this.parameterName = parameterName ?? value; + constructor(options: ICriterionOptionsParams) { + this.messageID = options.messageID; + this.type = options.type; + this.parameterName = options.parameterName ?? options.type; + this.modifierOptions = (options.modifierOptions ?? []).map((o) => + Criterion.getModifierOption(o) + ); + this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals; + this.options = options.options; + this.inputType = options.inputType; } } -export function createCriterionOption(value: CriterionType) { - return new CriterionOption(value, value); +export class StringCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.Includes, + CriterionModifier.Excludes, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + CriterionModifier.MatchesRegex, + CriterionModifier.NotMatchesRegex, + ], + defaultModifier: CriterionModifier.Equals, + options, + inputType: "text", + }); + } +} + +export function createStringCriterionOption(value: CriterionType) { + return new StringCriterionOption(value, value, value); } export class StringCriterion extends Criterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = [ - StringCriterion.getModifierOption(CriterionModifier.Equals), - StringCriterion.getModifierOption(CriterionModifier.NotEquals), - StringCriterion.getModifierOption(CriterionModifier.Includes), - StringCriterion.getModifierOption(CriterionModifier.Excludes), - StringCriterion.getModifierOption(CriterionModifier.IsNull), - StringCriterion.getModifierOption(CriterionModifier.NotNull), - StringCriterion.getModifierOption(CriterionModifier.MatchesRegex), - StringCriterion.getModifierOption(CriterionModifier.NotMatchesRegex), - ]; - public options: string[] | undefined; - public value: string = ""; - public getLabelValue() { return this.value; } @@ -218,74 +205,125 @@ export class StringCriterion extends Criterion { return str.replaceAll(c, encodeURIComponent(c)); } - constructor(type: CriterionOption, options?: string[]) { - super(type); - - this.options = options; - this.inputType = "text"; + constructor(type: CriterionOption) { + super(type, ""); } } -export class MandatoryStringCriterion extends StringCriterion { - public modifierOptions = [ - StringCriterion.getModifierOption(CriterionModifier.Equals), - StringCriterion.getModifierOption(CriterionModifier.NotEquals), - StringCriterion.getModifierOption(CriterionModifier.Includes), - StringCriterion.getModifierOption(CriterionModifier.Excludes), - StringCriterion.getModifierOption(CriterionModifier.MatchesRegex), - StringCriterion.getModifierOption(CriterionModifier.NotMatchesRegex), - ]; +export class MandatoryStringCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.Includes, + CriterionModifier.Excludes, + CriterionModifier.MatchesRegex, + CriterionModifier.NotMatchesRegex, + ], + defaultModifier: CriterionModifier.Equals, + options, + inputType: "text", + }); + } +} + +export class BooleanCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName?: string) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [], + defaultModifier: CriterionModifier.Equals, + options: [true.toString(), false.toString()], + }); + } } export class BooleanCriterion extends StringCriterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = []; - - constructor(type: CriterionOption) { - super(type, [true.toString(), false.toString()]); - } - protected toCriterionInput(): boolean { return this.value === "true"; } } -export class NumberCriterion extends Criterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - Criterion.getModifierOption(CriterionModifier.IsNull), - Criterion.getModifierOption(CriterionModifier.NotNull), - ]; - public options: number[] | undefined; - public value: number = 0; +export class NumberCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], + defaultModifier: CriterionModifier.Equals, + options, + inputType: "number", + }); + } +} +export function createNumberCriterionOption(value: CriterionType) { + return new NumberCriterionOption(value, value, value); +} + +export class NumberCriterion extends Criterion { public getLabelValue() { return this.value.toString(); } - constructor(type: CriterionOption, options?: number[]) { - super(type); - - this.options = options; - this.inputType = "number"; + constructor(type: CriterionOption) { + super(type, 0); } } -export abstract class ILabeledIdCriterion extends Criterion { - public modifier = CriterionModifier.IncludesAll; - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.IncludesAll), - Criterion.getModifierOption(CriterionModifier.Includes), - Criterion.getModifierOption(CriterionModifier.Excludes), - ]; +export class ILabeledIdCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName: string, + includeAll: boolean + ) { + const modifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.Excludes, + ]; - public options: IOptionType[] = []; - public value: ILabeledId[] = []; + let defaultModifier = CriterionModifier.Includes; + if (includeAll) { + modifierOptions.unshift(CriterionModifier.IncludesAll); + defaultModifier = CriterionModifier.IncludesAll; + } + super({ + messageID, + type: value, + parameterName, + modifierOptions, + defaultModifier, + }); + } +} + +export class ILabeledIdCriterion extends Criterion { public getLabelValue(): string { return this.value.map((v) => v.label).join(", "); } @@ -303,33 +341,12 @@ export abstract class ILabeledIdCriterion extends Criterion { }); } - constructor(type: CriterionOption, includeAll: boolean) { - super(type); - - if (!includeAll) { - this.modifier = CriterionModifier.Includes; - this.modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Includes), - Criterion.getModifierOption(CriterionModifier.Excludes), - ]; - } + constructor(type: CriterionOption) { + super(type, []); } } -export abstract class IHierarchicalLabeledIdCriterion extends Criterion { - public modifier = CriterionModifier.IncludesAll; - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.IncludesAll), - Criterion.getModifierOption(CriterionModifier.Includes), - Criterion.getModifierOption(CriterionModifier.Excludes), - ]; - - public options: IOptionType[] = []; - public value: IHierarchicalLabelValue = { - items: [], - depth: 0, - }; - +export class IHierarchicalLabeledIdCriterion extends Criterion { public encodeValue() { return { items: this.value.items.map((o) => { @@ -357,52 +374,41 @@ export abstract class IHierarchicalLabeledIdCriterion extends Criterion 0 ? this.value.depth : "all"})`; } - public toJSON() { - const encodedCriterion = { - type: this.criterionOption.value, - value: this.encodeValue(), - modifier: this.modifier, + constructor(type: CriterionOption) { + const value: IHierarchicalLabelValue = { + items: [], + depth: 0, }; - return JSON.stringify(encodedCriterion); - } - constructor(type: CriterionOption, includeAll: boolean) { - super(type); - - if (!includeAll) { - this.modifier = CriterionModifier.Includes; - this.modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Includes), - Criterion.getModifierOption(CriterionModifier.Excludes), - ]; - } + super(type, value); } } -export class MandatoryNumberCriterion extends NumberCriterion { - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - ]; +export class MandatoryNumberCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName?: string) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + ], + defaultModifier: CriterionModifier.Equals, + inputType: "number", + }); + } +} + +export function createMandatoryNumberCriterionOption(value: CriterionType) { + return new MandatoryNumberCriterionOption(value, value, value); } export class DurationCriterion extends Criterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - ]; - public options: number[] | undefined; - public value: number = 0; - - constructor(type: CriterionOption, options?: number[]) { - super(type); - - this.options = options; + constructor(type: CriterionOption) { + super(type, 0); } public getLabelValue() { diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index cf61d8efb..003b59885 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -3,25 +3,27 @@ import { StringCriterion, NumberCriterion, DurationCriterion, - MandatoryStringCriterion, - MandatoryNumberCriterion, - CriterionOption, + NumberCriterionOption, + MandatoryStringCriterionOption, + MandatoryNumberCriterionOption, + StringCriterionOption, + ILabeledIdCriterion, } from "./criterion"; import { OrganizedCriterion } from "./organized"; import { FavoriteCriterion } from "./favorite"; import { HasMarkersCriterion } from "./has-markers"; import { - PerformerIsMissingCriterion, - SceneIsMissingCriterion, - GalleryIsMissingCriterion, - TagIsMissingCriterion, - StudioIsMissingCriterion, - MovieIsMissingCriterion, - ImageIsMissingCriterion, + PerformerIsMissingCriterionOption, + ImageIsMissingCriterionOption, + TagIsMissingCriterionOption, + SceneIsMissingCriterionOption, + IsMissingCriterion, + GalleryIsMissingCriterionOption, + StudioIsMissingCriterionOption, + MovieIsMissingCriterionOption, } from "./is-missing"; import { NoneCriterion } from "./none"; import { PerformersCriterion } from "./performers"; -import { RatingCriterion } from "./rating"; import { AverageResolutionCriterion, ResolutionCriterion } from "./resolution"; import { StudiosCriterion, ParentStudiosCriterion } from "./studios"; import { @@ -31,19 +33,22 @@ import { TagsCriterionOption, } from "./tags"; import { GenderCriterion } from "./gender"; -import { MoviesCriterion } from "./movies"; +import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; import { InteractiveCriterion } from "./interactive"; +import { RatingCriterionOption } from "./rating"; export function makeCriteria(type: CriterionType = "none") { switch (type) { case "none": return new NoneCriterion(); case "path": - return new MandatoryStringCriterion(new CriterionOption(type, type)); + return new StringCriterion( + new MandatoryStringCriterionOption(type, type) + ); case "rating": - return new RatingCriterion(); + return new NumberCriterion(RatingCriterionOption); case "organized": return new OrganizedCriterion(); case "o_counter": @@ -53,31 +58,33 @@ export function makeCriteria(type: CriterionType = "none") { case "gallery_count": case "performer_count": case "tag_count": - return new MandatoryNumberCriterion(new CriterionOption(type, type)); + return new NumberCriterion( + new MandatoryNumberCriterionOption(type, type) + ); case "resolution": return new ResolutionCriterion(); case "average_resolution": return new AverageResolutionCriterion(); case "duration": - return new DurationCriterion(new CriterionOption(type, type)); + return new DurationCriterion(new NumberCriterionOption(type, type)); case "favorite": return new FavoriteCriterion(); case "hasMarkers": return new HasMarkersCriterion(); case "sceneIsMissing": - return new SceneIsMissingCriterion(); + return new IsMissingCriterion(SceneIsMissingCriterionOption); case "imageIsMissing": - return new ImageIsMissingCriterion(); + return new IsMissingCriterion(ImageIsMissingCriterionOption); case "performerIsMissing": - return new PerformerIsMissingCriterion(); + return new IsMissingCriterion(PerformerIsMissingCriterionOption); case "galleryIsMissing": - return new GalleryIsMissingCriterion(); + return new IsMissingCriterion(GalleryIsMissingCriterionOption); case "tagIsMissing": - return new TagIsMissingCriterion(); + return new IsMissingCriterion(TagIsMissingCriterionOption); case "studioIsMissing": - return new StudioIsMissingCriterion(); + return new IsMissingCriterion(StudioIsMissingCriterionOption); case "movieIsMissing": - return new MovieIsMissingCriterion(); + return new IsMissingCriterion(MovieIsMissingCriterionOption); case "tags": return new TagsCriterion(TagsCriterionOption); case "sceneTags": @@ -91,15 +98,17 @@ export function makeCriteria(type: CriterionType = "none") { case "parent_studios": return new ParentStudiosCriterion(); case "movies": - return new MoviesCriterion(); + return new ILabeledIdCriterion(MoviesCriterionOption); case "galleries": return new GalleriesCriterion(); case "birth_year": case "death_year": case "weight": - return new NumberCriterion(new CriterionOption(type, type)); + return new NumberCriterion(new NumberCriterionOption(type, type)); case "age": - return new MandatoryNumberCriterion(new CriterionOption(type, type)); + return new NumberCriterion( + new MandatoryNumberCriterionOption(type, type) + ); case "gender": return new GenderCriterion(); case "ethnicity": @@ -115,7 +124,7 @@ export function makeCriteria(type: CriterionType = "none") { case "aliases": case "url": case "stash_id": - return new StringCriterion(new CriterionOption(type, type)); + return new StringCriterion(new StringCriterionOption(type, type)); case "interactive": return new InteractiveCriterion(); } diff --git a/ui/v2.5/src/models/list-filter/criteria/favorite.ts b/ui/v2.5/src/models/list-filter/criteria/favorite.ts index 8bb61a4a4..1d5f2c03a 100644 --- a/ui/v2.5/src/models/list-filter/criteria/favorite.ts +++ b/ui/v2.5/src/models/list-filter/criteria/favorite.ts @@ -1,6 +1,6 @@ -import { BooleanCriterion, CriterionOption } from "./criterion"; +import { BooleanCriterion, BooleanCriterionOption } from "./criterion"; -export const FavoriteCriterionOption = new CriterionOption( +export const FavoriteCriterionOption = new BooleanCriterionOption( "favourite", "favorite", "filter_favorites" diff --git a/ui/v2.5/src/models/list-filter/criteria/galleries.ts b/ui/v2.5/src/models/list-filter/criteria/galleries.ts index 2beb15718..d2331cd3a 100644 --- a/ui/v2.5/src/models/list-filter/criteria/galleries.ts +++ b/ui/v2.5/src/models/list-filter/criteria/galleries.ts @@ -1,9 +1,14 @@ -import { CriterionOption, ILabeledIdCriterion } from "./criterion"; +import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -const galleriesCriterionOption = new CriterionOption("galleries", "galleries"); +const galleriesCriterionOption = new ILabeledIdCriterionOption( + "galleries", + "galleries", + "galleries", + true +); export class GalleriesCriterion extends ILabeledIdCriterion { constructor() { - super(galleriesCriterionOption, true); + super(galleriesCriterionOption); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/gender.ts b/ui/v2.5/src/models/list-filter/criteria/gender.ts index 3b7ddc0cc..58f2da712 100644 --- a/ui/v2.5/src/models/list-filter/criteria/gender.ts +++ b/ui/v2.5/src/models/list-filter/criteria/gender.ts @@ -1,18 +1,16 @@ -import { - CriterionModifier, - GenderCriterionInput, -} from "src/core/generated-graphql"; -import { getGenderStrings, stringToGender } from "src/core/StashService"; +import { GenderCriterionInput } from "src/core/generated-graphql"; +import { genderStrings, stringToGender } from "src/utils/gender"; import { CriterionOption, StringCriterion } from "./criterion"; -export const GenderCriterionOption = new CriterionOption("gender", "gender"); +export const GenderCriterionOption = new CriterionOption({ + messageID: "gender", + type: "gender", + options: genderStrings, +}); export class GenderCriterion extends StringCriterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = []; - constructor() { - super(GenderCriterionOption, getGenderStrings()); + super(GenderCriterionOption); } protected toCriterionInput(): GenderCriterionInput { diff --git a/ui/v2.5/src/models/list-filter/criteria/has-markers.ts b/ui/v2.5/src/models/list-filter/criteria/has-markers.ts index f0903431b..3f4109ef1 100644 --- a/ui/v2.5/src/models/list-filter/criteria/has-markers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/has-markers.ts @@ -1,14 +1,15 @@ import { CriterionOption, StringCriterion } from "./criterion"; -export const HasMarkersCriterionOption = new CriterionOption( - "hasMarkers", - "hasMarkers", - "has_markers" -); +export const HasMarkersCriterionOption = new CriterionOption({ + messageID: "hasMarkers", + type: "hasMarkers", + parameterName: "has_markers", + options: [true.toString(), false.toString()], +}); export class HasMarkersCriterion extends StringCriterion { constructor() { - super(HasMarkersCriterionOption, [true.toString(), false.toString()]); + super(HasMarkersCriterionOption); } protected toCriterionInput(): string { diff --git a/ui/v2.5/src/models/list-filter/criteria/interactive.ts b/ui/v2.5/src/models/list-filter/criteria/interactive.ts index c94facbec..fd7da07dd 100644 --- a/ui/v2.5/src/models/list-filter/criteria/interactive.ts +++ b/ui/v2.5/src/models/list-filter/criteria/interactive.ts @@ -1,6 +1,6 @@ -import { BooleanCriterion, CriterionOption } from "./criterion"; +import { BooleanCriterion, BooleanCriterionOption } from "./criterion"; -export const InteractiveCriterionOption = new CriterionOption( +export const InteractiveCriterionOption = new BooleanCriterionOption( "interactive", "interactive" ); diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 449814ea9..1cc71d168 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -1,7 +1,8 @@ import { CriterionModifier } from "src/core/generated-graphql"; -import { CriterionOption, StringCriterion } from "./criterion"; +import { CriterionType } from "../types"; +import { CriterionOption, StringCriterion, Option } from "./criterion"; -export abstract class IsMissingCriterion extends StringCriterion { +export class IsMissingCriterion extends StringCriterion { public modifierOptions = []; public modifier = CriterionModifier.Equals; @@ -10,136 +11,98 @@ export abstract class IsMissingCriterion extends StringCriterion { } } -export const SceneIsMissingCriterionOption = new CriterionOption( +class IsMissingCriterionOptionClass extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName: string, + options: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + options, + }); + } +} + +export const SceneIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "sceneIsMissing", - "is_missing" + "is_missing", + [ + "title", + "details", + "url", + "date", + "galleries", + "studio", + "movie", + "performers", + "tags", + "stash_id", + ] ); -export class SceneIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(SceneIsMissingCriterionOption, [ - "title", - "details", - "url", - "date", - "galleries", - "studio", - "movie", - "performers", - "tags", - "stash_id", - ]); - } -} - -export const ImageIsMissingCriterionOption = new CriterionOption( +export const ImageIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "imageIsMissing", - "is_missing" + "is_missing", + ["title", "galleries", "studio", "performers", "tags"] ); -export class ImageIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(ImageIsMissingCriterionOption, [ - "title", - "galleries", - "studio", - "performers", - "tags", - ]); - } -} - -export const PerformerIsMissingCriterionOption = new CriterionOption( +export const PerformerIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "performerIsMissing", - "is_missing" + "is_missing", + [ + "url", + "twitter", + "instagram", + "ethnicity", + "country", + "hair_color", + "eye_color", + "height", + "weight", + "measurements", + "fake_tits", + "career_length", + "tattoos", + "piercings", + "aliases", + "gender", + "image", + "details", + "stash_id", + ] ); -export class PerformerIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(PerformerIsMissingCriterionOption, [ - "url", - "twitter", - "instagram", - "ethnicity", - "country", - "hair_color", - "eye_color", - "height", - "weight", - "measurements", - "fake_tits", - "career_length", - "tattoos", - "piercings", - "aliases", - "gender", - "image", - "details", - "stash_id", - ]); - } -} - -export const GalleryIsMissingCriterionOption = new CriterionOption( +export const GalleryIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "galleryIsMissing", - "is_missing" + "is_missing", + ["title", "details", "url", "date", "studio", "performers", "tags", "scenes"] ); -export class GalleryIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(GalleryIsMissingCriterionOption, [ - "title", - "details", - "url", - "date", - "studio", - "performers", - "tags", - "scenes", - ]); - } -} - -export const TagIsMissingCriterionOption = new CriterionOption( +export const TagIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "tagIsMissing", - "is_missing" + "is_missing", + ["image"] ); -export class TagIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(TagIsMissingCriterionOption, ["image"]); - } -} - -export const StudioIsMissingCriterionOption = new CriterionOption( +export const StudioIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "studioIsMissing", - "is_missing" + "is_missing", + ["image", "stash_id", "details"] ); -export class StudioIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(StudioIsMissingCriterionOption, ["image", "stash_id", "details"]); - } -} - -export const MovieIsMissingCriterionOption = new CriterionOption( +export const MovieIsMissingCriterionOption = new IsMissingCriterionOptionClass( "isMissing", "movieIsMissing", - "is_missing" + "is_missing", + ["front_image", "back_image", "scenes"] ); - -export class MovieIsMissingCriterion extends IsMissingCriterion { - constructor() { - super(MovieIsMissingCriterionOption, [ - "front_image", - "back_image", - "scenes", - ]); - } -} diff --git a/ui/v2.5/src/models/list-filter/criteria/movies.ts b/ui/v2.5/src/models/list-filter/criteria/movies.ts index 63f516428..69cac4f9e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/movies.ts +++ b/ui/v2.5/src/models/list-filter/criteria/movies.ts @@ -1,9 +1,14 @@ -import { CriterionOption, ILabeledIdCriterion } from "./criterion"; +import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -export const MoviesCriterionOption = new CriterionOption("movies", "movies"); +export const MoviesCriterionOption = new ILabeledIdCriterionOption( + "movies", + "movies", + "movies", + false +); export class MoviesCriterion extends ILabeledIdCriterion { constructor() { - super(MoviesCriterionOption, false); + super(MoviesCriterionOption); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/none.ts b/ui/v2.5/src/models/list-filter/criteria/none.ts index be3d3215c..a650e3ea8 100644 --- a/ui/v2.5/src/models/list-filter/criteria/none.ts +++ b/ui/v2.5/src/models/list-filter/criteria/none.ts @@ -1,15 +1,13 @@ -import { CriterionModifier } from "src/core/generated-graphql"; -import { Criterion, CriterionOption } from "./criterion"; +import { Criterion, StringCriterionOption } from "./criterion"; -export const NoneCriterionOption = new CriterionOption("none", "none"); +export const NoneCriterionOption = new StringCriterionOption( + "none", + "none", + "none" +); export class NoneCriterion extends Criterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = []; - public options: undefined; - public value: string = "none"; - constructor() { - super(NoneCriterionOption); + super(NoneCriterionOption, "none"); } // eslint-disable-next-line class-methods-use-this diff --git a/ui/v2.5/src/models/list-filter/criteria/organized.ts b/ui/v2.5/src/models/list-filter/criteria/organized.ts index dd008985d..526750208 100644 --- a/ui/v2.5/src/models/list-filter/criteria/organized.ts +++ b/ui/v2.5/src/models/list-filter/criteria/organized.ts @@ -1,6 +1,7 @@ -import { BooleanCriterion, CriterionOption } from "./criterion"; +import { BooleanCriterion, BooleanCriterionOption } from "./criterion"; -export const OrganizedCriterionOption = new CriterionOption( +export const OrganizedCriterionOption = new BooleanCriterionOption( + "organized", "organized", "organized" ); diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index 0d67aed75..7b177d939 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -1,12 +1,14 @@ -import { CriterionOption, ILabeledIdCriterion } from "./criterion"; +import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -export const PerformersCriterionOption = new CriterionOption( +export const PerformersCriterionOption = new ILabeledIdCriterionOption( "performers", - "performers" + "performers", + "performers", + true ); export class PerformersCriterion extends ILabeledIdCriterion { constructor() { - super(PerformersCriterionOption, true); + super(PerformersCriterionOption); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/rating.ts b/ui/v2.5/src/models/list-filter/criteria/rating.ts index 056f29006..d9aa8e89f 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -1,20 +1,8 @@ -import { CriterionModifier } from "src/core/generated-graphql"; -import { Criterion, CriterionOption, NumberCriterion } from "./criterion"; +import { NumberCriterionOption } from "./criterion"; -export const RatingCriterionOption = new CriterionOption("rating", "rating"); - -export class RatingCriterion extends NumberCriterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - Criterion.getModifierOption(CriterionModifier.IsNull), - Criterion.getModifierOption(CriterionModifier.NotNull), - ]; - - constructor() { - super(RatingCriterionOption, [1, 2, 3, 4, 5]); - } -} +export const RatingCriterionOption = new NumberCriterionOption( + "rating", + "rating", + "rating", + [1, 2, 3, 4, 5] +); diff --git a/ui/v2.5/src/models/list-filter/criteria/resolution.ts b/ui/v2.5/src/models/list-filter/criteria/resolution.ts index 545632d57..a5fb54de0 100644 --- a/ui/v2.5/src/models/list-filter/criteria/resolution.ts +++ b/ui/v2.5/src/models/list-filter/criteria/resolution.ts @@ -1,27 +1,8 @@ -import { CriterionModifier, ResolutionEnum } from "src/core/generated-graphql"; +import { ResolutionEnum } from "src/core/generated-graphql"; +import { CriterionType } from "../types"; import { CriterionOption, StringCriterion } from "./criterion"; abstract class AbstractResolutionCriterion extends StringCriterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = []; - - constructor(type: CriterionOption) { - super(type, [ - "144p", - "240p", - "360p", - "480p", - "540p", - "720p", - "1080p", - "1440p", - "4k", - "5k", - "6k", - "8k", - ]); - } - protected toCriterionInput(): ResolutionEnum | undefined { switch (this.value) { case "144p": @@ -55,21 +36,40 @@ abstract class AbstractResolutionCriterion extends StringCriterion { } } -export const ResolutionCriterionOption = new CriterionOption( - "resolution", +class ResolutionCriterionOptionType extends CriterionOption { + constructor(value: CriterionType) { + super({ + messageID: value, + type: value, + parameterName: value, + options: [ + "144p", + "240p", + "360p", + "480p", + "540p", + "720p", + "1080p", + "1440p", + "4k", + "5k", + "6k", + "8k", + ], + }); + } +} + +export const ResolutionCriterionOption = new ResolutionCriterionOptionType( "resolution" ); export class ResolutionCriterion extends AbstractResolutionCriterion { - public modifier = CriterionModifier.Equals; - public modifierOptions = []; - constructor() { super(ResolutionCriterionOption); } } -export const AverageResolutionCriterionOption = new CriterionOption( - "average_resolution", +export const AverageResolutionCriterionOption = new ResolutionCriterionOptionType( "average_resolution" ); diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index e351b5837..455921543 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -1,23 +1,30 @@ import { - CriterionOption, IHierarchicalLabeledIdCriterion, ILabeledIdCriterion, + ILabeledIdCriterionOption, } from "./criterion"; -export const StudiosCriterionOption = new CriterionOption("studios", "studios"); +export const StudiosCriterionOption = new ILabeledIdCriterionOption( + "studios", + "studios", + "studios", + false +); + export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { constructor() { - super(StudiosCriterionOption, false); + super(StudiosCriterionOption); } } -export const ParentStudiosCriterionOption = new CriterionOption( +export const ParentStudiosCriterionOption = new ILabeledIdCriterionOption( "parent_studios", "parent_studios", - "parents" + "parents", + false ); export class ParentStudiosCriterion extends ILabeledIdCriterion { constructor() { - super(ParentStudiosCriterionOption, false); + super(ParentStudiosCriterionOption); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index 0b61c823a..04545440e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -1,19 +1,22 @@ -import { CriterionOption, ILabeledIdCriterion } from "./criterion"; +import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -export class TagsCriterion extends ILabeledIdCriterion { - constructor(type: CriterionOption) { - super(type, true); - } -} +export class TagsCriterion extends ILabeledIdCriterion {} -export const TagsCriterionOption = new CriterionOption("tags", "tags"); -export const SceneTagsCriterionOption = new CriterionOption( +export const TagsCriterionOption = new ILabeledIdCriterionOption( + "tags", + "tags", + "tags", + true +); +export const SceneTagsCriterionOption = new ILabeledIdCriterionOption( "sceneTags", "sceneTags", - "scene_tags" + "scene_tags", + true ); -export const PerformerTagsCriterionOption = new CriterionOption( +export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption( "performerTags", "performerTags", - "performer_tags" + "performer_tags", + true ); diff --git a/ui/v2.5/src/models/list-filter/factory.ts b/ui/v2.5/src/models/list-filter/factory.ts index 970bd91ed..36b9d66db 100644 --- a/ui/v2.5/src/models/list-filter/factory.ts +++ b/ui/v2.5/src/models/list-filter/factory.ts @@ -1,3 +1,4 @@ +import { FilterMode } from "src/core/generated-graphql"; import { ListFilterOptions } from "./filter-options"; import { GalleryListFilterOptions } from "./galleries"; import { ImageListFilterOptions } from "./images"; @@ -7,7 +8,6 @@ import { SceneMarkerListFilterOptions } from "./scene-markers"; import { SceneListFilterOptions } from "./scenes"; import { StudioListFilterOptions } from "./studios"; import { TagListFilterOptions } from "./tags"; -import { FilterMode } from "./types"; export function getFilterOptions(mode: FilterMode): ListFilterOptions { switch (mode) { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index a90c1c973..c08337258 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -1,5 +1,9 @@ import queryString, { ParsedQuery } from "query-string"; -import { FindFilterType, SortDirectionEnum } from "src/core/generated-graphql"; +import { + FilterMode, + FindFilterType, + SortDirectionEnum, +} from "src/core/generated-graphql"; import { Criterion, CriterionValue } from "./criteria/criterion"; import { makeCriteria } from "./criteria/factory"; import { DisplayMode } from "./types"; @@ -23,6 +27,7 @@ const DEFAULT_PARAMS = { // TODO: handle customCriteria export class ListFilterModel { + public mode: FilterMode; public searchTerm?: string; public currentPage = DEFAULT_PARAMS.currentPage; public itemsPerPage = DEFAULT_PARAMS.itemsPerPage; @@ -33,16 +38,22 @@ export class ListFilterModel { public randomSeed = -1; public constructor( + mode: FilterMode, rawParms?: ParsedQuery, defaultSort?: string, defaultDisplayMode?: DisplayMode ) { + this.mode = mode; const params = rawParms as IQueryParameters; this.sortBy = defaultSort; if (defaultDisplayMode !== undefined) this.displayMode = defaultDisplayMode; if (params) this.configureFromQueryParameters(params); } + public clone() { + return Object.assign(new ListFilterModel(this.mode), this); + } + public configureFromQueryParameters(params: IQueryParameters) { if (params.sortby !== undefined) { this.sortBy = params.sortby; @@ -64,7 +75,7 @@ export class ListFilterModel { params.sortdir === "desc" ? SortDirectionEnum.Desc : SortDirectionEnum.Asc; - if (params.disp) { + if (params.disp !== undefined) { this.displayMode = Number.parseInt(params.disp, 10); } if (params.q) { @@ -153,6 +164,24 @@ export class ListFilterModel { return result; } + public getSavedQueryParameters() { + const encodedCriteria: string[] = this.criteria.map((criterion) => + criterion.toJSON() + ); + + const result = { + perPage: this.itemsPerPage, + sortby: this.getSortBy() ?? undefined, + sortdir: + this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, + disp: this.displayMode, + q: this.searchTerm, + c: encodedCriteria, + }; + + return result; + } + public makeQueryParameters(): string { return queryString.stringify(this.getQueryParameters(), { encode: false }); } diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 8c9b0f6ae..cbb18cce2 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -1,6 +1,5 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { createStringCriterionOption } from "./criteria/criterion"; import { GalleryIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; import { RatingCriterionOption } from "./criteria/rating"; @@ -39,20 +38,19 @@ const displayModeOptions = [ ]; const criterionOptions = [ - NoneCriterionOption, - createCriterionOption("path"), + createStringCriterionOption("path"), RatingCriterionOption, OrganizedCriterionOption, AverageResolutionCriterionOption, GalleryIsMissingCriterionOption, TagsCriterionOption, - createCriterionOption("tag_count"), + createStringCriterionOption("tag_count"), PerformerTagsCriterionOption, PerformersCriterionOption, - createCriterionOption("performer_count"), - createCriterionOption("image_count"), + createStringCriterionOption("performer_count"), + createStringCriterionOption("image_count"), StudiosCriterionOption, - createCriterionOption("url"), + createStringCriterionOption("url"), ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 84b668f1b..25f98f68f 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -1,6 +1,8 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { + createMandatoryNumberCriterionOption, + createStringCriterionOption, +} from "./criteria/criterion"; import { ImageIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; import { RatingCriterionOption } from "./criteria/rating"; @@ -29,18 +31,17 @@ const sortByOptions = [ const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; const criterionOptions = [ - NoneCriterionOption, - createCriterionOption("path"), + createStringCriterionOption("path"), RatingCriterionOption, OrganizedCriterionOption, - createCriterionOption("o_counter"), + createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, ImageIsMissingCriterionOption, TagsCriterionOption, - createCriterionOption("tag_count"), + createMandatoryNumberCriterionOption("tag_count"), PerformerTagsCriterionOption, PerformersCriterionOption, - createCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("performer_count"), StudiosCriterionOption, ]; export const ImageListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 29bf0faee..72e7256a6 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -1,6 +1,5 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { createStringCriterionOption } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { StudiosCriterionOption } from "./criteria/studios"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; @@ -17,10 +16,9 @@ const sortByOptions = ["name", "random"] ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ - NoneCriterionOption, StudiosCriterionOption, MovieIsMissingCriterionOption, - createCriterionOption("url"), + createStringCriterionOption("url"), ]; export const MovieListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 23d28943f..3e1f48e80 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -1,8 +1,11 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { + createNumberCriterionOption, + createMandatoryNumberCriterionOption, + createStringCriterionOption, +} from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; import { GenderCriterionOption } from "./criteria/gender"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { RatingCriterionOption } from "./criteria/rating"; import { StudiosCriterionOption } from "./criteria/studios"; import { TagsCriterionOption } from "./criteria/tags"; @@ -55,19 +58,19 @@ const stringCriteria: CriterionType[] = [ ]; const criterionOptions = [ - NoneCriterionOption, FavoriteCriterionOption, GenderCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, RatingCriterionOption, StudiosCriterionOption, - createCriterionOption("url"), - createCriterionOption("tag_count"), - createCriterionOption("scene_count"), - createCriterionOption("image_count"), - createCriterionOption("gallery_count"), - ...numberCriteria.concat(stringCriteria).map((c) => createCriterionOption(c)), + createStringCriterionOption("url"), + createMandatoryNumberCriterionOption("tag_count"), + createMandatoryNumberCriterionOption("scene_count"), + createMandatoryNumberCriterionOption("image_count"), + createMandatoryNumberCriterionOption("gallery_count"), + ...numberCriteria.map((c) => createNumberCriterionOption(c)), + ...stringCriteria.map((c) => createStringCriterionOption(c)), ]; export const PerformerListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scene-markers.ts b/ui/v2.5/src/models/list-filter/scene-markers.ts index 3d25df658..0080e017e 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -1,4 +1,3 @@ -import { NoneCriterionOption } from "./criteria/none"; import { PerformersCriterionOption } from "./criteria/performers"; import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; @@ -14,7 +13,6 @@ const sortByOptions = [ ].map(ListFilterOptions.createSortBy); const displayModeOptions = [DisplayMode.Wall]; const criterionOptions = [ - NoneCriterionOption, TagsCriterionOption, SceneTagsCriterionOption, PerformersCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 91690e73d..380ee606d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -1,8 +1,10 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { + createMandatoryNumberCriterionOption, + createStringCriterionOption, +} from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; import { MoviesCriterionOption } from "./criteria/movies"; -import { NoneCriterionOption } from "./criteria/none"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; import { RatingCriterionOption } from "./criteria/rating"; @@ -44,24 +46,23 @@ const displayModeOptions = [ ]; const criterionOptions = [ - NoneCriterionOption, - createCriterionOption("path"), + createStringCriterionOption("path"), RatingCriterionOption, OrganizedCriterionOption, - createCriterionOption("o_counter"), + createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, - createCriterionOption("duration"), + createMandatoryNumberCriterionOption("duration"), HasMarkersCriterionOption, SceneIsMissingCriterionOption, TagsCriterionOption, - createCriterionOption("tag_count"), + createMandatoryNumberCriterionOption("tag_count"), PerformerTagsCriterionOption, PerformersCriterionOption, - createCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("performer_count"), StudiosCriterionOption, MoviesCriterionOption, - createCriterionOption("url"), - createCriterionOption("stash_id"), + createStringCriterionOption("url"), + createStringCriterionOption("stash_id"), InteractiveCriterionOption, ]; diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index 1b05fcef4..ff52b0d27 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -1,6 +1,8 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { + createMandatoryNumberCriterionOption, + createStringCriterionOption, +} from "./criteria/criterion"; import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { RatingCriterionOption } from "./criteria/rating"; import { ParentStudiosCriterionOption } from "./criteria/studios"; import { ListFilterOptions } from "./filter-options"; @@ -26,15 +28,14 @@ const sortByOptions = ["name", "random", "rating"] const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ - NoneCriterionOption, ParentStudiosCriterionOption, StudioIsMissingCriterionOption, RatingCriterionOption, - createCriterionOption("scene_count"), - createCriterionOption("image_count"), - createCriterionOption("gallery_count"), - createCriterionOption("url"), - createCriterionOption("stash_id"), + createMandatoryNumberCriterionOption("scene_count"), + createMandatoryNumberCriterionOption("image_count"), + createMandatoryNumberCriterionOption("gallery_count"), + createStringCriterionOption("url"), + createStringCriterionOption("stash_id"), ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 2c5e6ab75..fd533d14b 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -1,6 +1,5 @@ -import { createCriterionOption } from "./criteria/criterion"; +import { createMandatoryNumberCriterionOption } from "./criteria/criterion"; import { TagIsMissingCriterionOption } from "./criteria/is-missing"; -import { NoneCriterionOption } from "./criteria/none"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; @@ -34,12 +33,11 @@ const sortByOptions = [ const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; const criterionOptions = [ - NoneCriterionOption, TagIsMissingCriterionOption, - createCriterionOption("scene_count"), - createCriterionOption("image_count"), - createCriterionOption("gallery_count"), - createCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("scene_count"), + createMandatoryNumberCriterionOption("image_count"), + createMandatoryNumberCriterionOption("gallery_count"), + createMandatoryNumberCriterionOption("performer_count"), // marker count has been disabled for now due to performance issues // ListFilterModel.createCriterionOption("marker_count"), ]; diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index e3f61f398..c15ab94f6 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -8,17 +8,6 @@ export enum DisplayMode { Tagger, } -export enum FilterMode { - Scenes, - Performers, - Studios, - Galleries, - SceneMarkers, - Movies, - Tags, - Images, -} - export interface ILabeledId { id: string; label: string; diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index d86225a13..512927bde 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -1,5 +1,6 @@ import queryString from "query-string"; import { RouteComponentProps } from "react-router-dom"; +import { FilterMode } from "src/core/generated-graphql"; import { ListFilterModel } from "./list-filter/filter"; import { SceneListFilterOptions } from "./list-filter/scenes"; @@ -27,7 +28,7 @@ export class SceneQueue { public static fromListFilterModel(filter: ListFilterModel) { const ret = new SceneQueue(); - const filterCopy = Object.assign(new ListFilterModel(), filter); + const filterCopy = filter.clone(); filterCopy.itemsPerPage = 40; ret.originalQueryPage = filter.currentPage; @@ -95,6 +96,7 @@ export class SceneQueue { if (parsed.qfp) { const query = new ListFilterModel( + FilterMode.Scenes, translated as queryString.ParsedQuery, SceneListFilterOptions.defaultSortBy ); diff --git a/ui/v2.5/src/utils/gender.ts b/ui/v2.5/src/utils/gender.ts new file mode 100644 index 000000000..1a1934468 --- /dev/null +++ b/ui/v2.5/src/utils/gender.ts @@ -0,0 +1,49 @@ +import * as GQL from "../core/generated-graphql"; + +export const stringGenderMap = new Map([ + ["Male", GQL.GenderEnum.Male], + ["Female", GQL.GenderEnum.Female], + ["Transgender Male", GQL.GenderEnum.TransgenderMale], + ["Transgender Female", GQL.GenderEnum.TransgenderFemale], + ["Intersex", GQL.GenderEnum.Intersex], + ["Non-Binary", GQL.GenderEnum.NonBinary], +]); + +export const genderToString = (value?: GQL.GenderEnum | string) => { + if (!value) { + return undefined; + } + + const foundEntry = Array.from(stringGenderMap.entries()).find((e) => { + return e[1] === value; + }); + + if (foundEntry) { + return foundEntry[0]; + } +}; + +export const stringToGender = ( + value?: string | null, + caseInsensitive?: boolean +) => { + if (!value) { + return undefined; + } + + const ret = stringGenderMap.get(value); + if (ret || !caseInsensitive) { + return ret; + } + + const asUpper = value.toUpperCase(); + const foundEntry = Array.from(stringGenderMap.entries()).find((e) => { + return e[0].toUpperCase() === asUpper; + }); + + if (foundEntry) { + return foundEntry[1]; + } +}; + +export const genderStrings = Array.from(stringGenderMap.keys()); diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 98fd885c1..f4af524cb 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -30,7 +30,7 @@ const makePerformerScenesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Scenes); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -45,7 +45,7 @@ const makePerformerImagesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Images); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -60,7 +60,7 @@ const makePerformerGalleriesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Galleries); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -74,7 +74,7 @@ const makePerformersCountryUrl = ( performer: Partial ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Performers); const criterion = new CountryCriterion(); criterion.value = `${performer.country}`; filter.criteria.push(criterion); @@ -83,7 +83,7 @@ const makePerformersCountryUrl = ( const makeStudioScenesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Scenes); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -95,7 +95,7 @@ const makeStudioScenesUrl = (studio: Partial) => { const makeStudioImagesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Images); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -107,7 +107,7 @@ const makeStudioImagesUrl = (studio: Partial) => { const makeStudioGalleriesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Galleries); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -119,7 +119,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const makeChildStudiosUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Studios); const criterion = new ParentStudiosCriterion(); criterion.value = [ { id: studio.id, label: studio.name || `Studio ${studio.id}` }, @@ -130,7 +130,7 @@ const makeChildStudiosUrl = (studio: Partial) => { const makeMovieScenesUrl = (movie: Partial) => { if (!movie.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Scenes); const criterion = new MoviesCriterion(); criterion.value = [ { id: movie.id, label: movie.name || `Movie ${movie.id}` }, @@ -141,7 +141,7 @@ const makeMovieScenesUrl = (movie: Partial) => { const makeTagScenesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Scenes); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; filter.criteria.push(criterion); @@ -150,7 +150,7 @@ const makeTagScenesUrl = (tag: Partial) => { const makeTagPerformersUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Performers); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; filter.criteria.push(criterion); @@ -159,7 +159,7 @@ const makeTagPerformersUrl = (tag: Partial) => { const makeTagSceneMarkersUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; filter.criteria.push(criterion); @@ -168,7 +168,7 @@ const makeTagSceneMarkersUrl = (tag: Partial) => { const makeTagGalleriesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Galleries); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; filter.criteria.push(criterion); @@ -177,7 +177,7 @@ const makeTagGalleriesUrl = (tag: Partial) => { const makeTagImagesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(); + const filter = new ListFilterModel(GQL.FilterMode.Images); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; filter.criteria.push(criterion);