Saved filters (#1474)

* Refactor list filter
* Filter/criterion refactor
* Rename option value to type
* Remove None from options
* Add saved filter button
* Integrate default filters
This commit is contained in:
WithoutPants
2021-06-16 14:53:32 +10:00
committed by GitHub
parent 4fe4da6c01
commit dc7584d77e
74 changed files with 2583 additions and 1263 deletions

View File

@@ -52,5 +52,7 @@ models:
model: github.com/stashapp/stash/pkg/models.ScrapedMovie model: github.com/stashapp/stash/pkg/models.ScrapedMovie
ScrapedMovieStudio: ScrapedMovieStudio:
model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio
SavedFilter:
model: github.com/stashapp/stash/pkg/models.SavedFilter
StashID: StashID:
model: github.com/stashapp/stash/pkg/models.StashID model: github.com/stashapp/stash/pkg/models.StashID

View File

@@ -0,0 +1,6 @@
fragment SavedFilterData on SavedFilter {
id
mode
name
filter
}

View File

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

View File

@@ -0,0 +1,11 @@
query FindSavedFilters($mode: FilterMode!) {
findSavedFilters(mode: $mode) {
...SavedFilterData
}
}
query FindDefaultFilter($mode: FilterMode!) {
findDefaultFilter(mode: $mode) {
...SavedFilterData
}
}

View File

@@ -1,5 +1,9 @@
"""The query root for this schema""" """The query root for this schema"""
type Query { type Query {
# Filters
findSavedFilters(mode: FilterMode!): [SavedFilter!]!
findDefaultFilter(mode: FilterMode!): SavedFilter
"""Find a scene by ID or Checksum""" """Find a scene by ID or Checksum"""
findScene(id: ID, checksum: String): Scene findScene(id: ID, checksum: String): Scene
findSceneByHash(input: SceneHashInput!): Scene findSceneByHash(input: SceneHashInput!): Scene
@@ -199,6 +203,11 @@ type Mutation {
tagsDestroy(ids: [ID!]!): Boolean! tagsDestroy(ids: [ID!]!): Boolean!
tagsMerge(input: TagsMergeInput!): Tag tagsMerge(input: TagsMergeInput!): Tag
# Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter!
destroySavedFilter(input: DestroyFilterInput!): Boolean!
setDefaultFilter(input: SetDefaultFilterInput!): Boolean!
"""Change general configuration options""" """Change general configuration options"""
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!
configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult! configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!

View File

@@ -317,3 +317,41 @@ input HierarchicalMultiCriterionInput {
modifier: CriterionModifier! modifier: CriterionModifier!
depth: Int! 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
}

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var WriteMu *sync.Mutex var WriteMu *sync.Mutex
var dbPath string var dbPath string
var appSchemaVersion uint = 24 var appSchemaVersion uint = 25
var databaseSchemaVersion uint var databaseSchemaVersion uint
var ( var (

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ type TransactionManager struct {
scrapedItem models.ScrapedItemReaderWriter scrapedItem models.ScrapedItemReaderWriter
studio models.StudioReaderWriter studio models.StudioReaderWriter
tag models.TagReaderWriter tag models.TagReaderWriter
savedFilter models.SavedFilterReaderWriter
} }
func NewTransactionManager() *TransactionManager { func NewTransactionManager() *TransactionManager {
@@ -29,6 +30,7 @@ func NewTransactionManager() *TransactionManager {
scrapedItem: &ScrapedItemReaderWriter{}, scrapedItem: &ScrapedItemReaderWriter{},
studio: &StudioReaderWriter{}, studio: &StudioReaderWriter{},
tag: &TagReaderWriter{}, tag: &TagReaderWriter{},
savedFilter: &SavedFilterReaderWriter{},
} }
} }
@@ -72,6 +74,10 @@ func (t *TransactionManager) Tag() models.TagReaderWriter {
return t.tag return t.tag
} }
func (t *TransactionManager) SavedFilter() models.SavedFilterReaderWriter {
return t.savedFilter
}
type ReadTransaction struct { type ReadTransaction struct {
t *TransactionManager t *TransactionManager
} }
@@ -115,3 +121,7 @@ func (r *ReadTransaction) Studio() models.StudioReader {
func (r *ReadTransaction) Tag() models.TagReader { func (r *ReadTransaction) Tag() models.TagReader {
return r.t.tag return r.t.tag
} }
func (r *ReadTransaction) SavedFilter() models.SavedFilterReader {
return r.t.savedFilter
}

View File

@@ -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{}
}

View File

@@ -10,6 +10,7 @@ type Repository interface {
ScrapedItem() ScrapedItemReaderWriter ScrapedItem() ScrapedItemReaderWriter
Studio() StudioReaderWriter Studio() StudioReaderWriter
Tag() TagReaderWriter Tag() TagReaderWriter
SavedFilter() SavedFilterReaderWriter
} }
type ReaderRepository interface { type ReaderRepository interface {
@@ -22,4 +23,5 @@ type ReaderRepository interface {
ScrapedItem() ScrapedItemReader ScrapedItem() ScrapedItemReader
Studio() StudioReader Studio() StudioReader
Tag() TagReader Tag() TagReader
SavedFilter() SavedFilterReader
} }

View File

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

109
pkg/sqlite/saved_filter.go Normal file
View File

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

View File

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

View File

@@ -189,22 +189,34 @@ const (
) )
const ( const (
pathField = "Path" savedFilterIdxDefaultScene = iota
checksumField = "Checksum" savedFilterIdxDefaultImage
titleField = "Title" savedFilterIdxScene
urlField = "URL" savedFilterIdxImage
zipPath = "zipPath.zip"
// new indexes above
totalSavedFilters
)
const (
pathField = "Path"
checksumField = "Checksum"
titleField = "Title"
urlField = "URL"
zipPath = "zipPath.zip"
firstSavedFilterName = "firstSavedFilterName"
) )
var ( var (
sceneIDs []int sceneIDs []int
imageIDs []int imageIDs []int
performerIDs []int performerIDs []int
movieIDs []int movieIDs []int
galleryIDs []int galleryIDs []int
tagIDs []int tagIDs []int
studioIDs []int studioIDs []int
markerIDs []int markerIDs []int
savedFilterIDs []int
tagNames []string tagNames []string
studioNames []string studioNames []string
@@ -423,6 +435,10 @@ func populateDB() error {
return fmt.Errorf("error creating studios: %s", err.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 { if err := linkPerformerTags(r.Performer()); err != nil {
return fmt.Errorf("error linking performer tags: %s", err.Error()) return fmt.Errorf("error linking performer tags: %s", err.Error())
} }
@@ -979,6 +995,51 @@ func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx in
return nil 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 { func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error {
for _, l := range links { for _, l := range links {
if err := fn(l[0], l[1]); err != nil { if err := fn(l[0], l[1]); err != nil {

View File

@@ -125,6 +125,11 @@ func (t *transaction) Tag() models.TagReaderWriter {
return NewTagReaderWriter(t.tx) return NewTagReaderWriter(t.tx)
} }
func (t *transaction) SavedFilter() models.SavedFilterReaderWriter {
t.ensureTx()
return NewSavedFilterReaderWriter(t.tx)
}
type ReadTransaction struct{} type ReadTransaction struct{}
func (t *ReadTransaction) Begin() error { func (t *ReadTransaction) Begin() error {
@@ -183,6 +188,10 @@ func (t *ReadTransaction) Tag() models.TagReader {
return NewTagReaderWriter(database.DB) return NewTagReaderWriter(database.DB)
} }
func (t *ReadTransaction) SavedFilter() models.SavedFilterReader {
return NewSavedFilterReaderWriter(database.DB)
}
type TransactionManager struct { type TransactionManager struct {
} }

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ 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 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)) * 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)) * Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397))

View File

@@ -24,7 +24,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
}; };
// if galleries is already present, then we modify it, otherwise add // if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => { let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "galleries"; return c.criterionOption.type === "galleries";
}) as GalleriesCriterion; }) as GalleriesCriterion;
if ( if (

View File

@@ -26,7 +26,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
}; };
// if galleries is already present, then we modify it, otherwise add // if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => { let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "galleries"; return c.criterionOption.type === "galleries";
}) as GalleriesCriterion; }) as GalleriesCriterion;
if ( if (

View File

@@ -1,8 +1,7 @@
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Button, Form, Modal, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Button, Form, Modal } from "react-bootstrap";
import Mousetrap from "mousetrap"; import { FilterSelect, DurationInput } from "src/components/Shared";
import { Icon, FilterSelect, DurationInput } from "src/components/Shared";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
DurationCriterion, DurationCriterion,
@@ -10,7 +9,10 @@ import {
Criterion, Criterion,
IHierarchicalLabeledIdCriterion, IHierarchicalLabeledIdCriterion,
} from "src/models/list-filter/criteria/criterion"; } 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 { makeCriteria } from "src/models/list-filter/criteria/factory";
import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { defineMessages, FormattedMessage, useIntl } from "react-intl"; import { defineMessages, FormattedMessage, useIntl } from "react-intl";
@@ -29,15 +31,18 @@ interface IAddFilterProps {
editingCriterion?: Criterion<CriterionValue>; editingCriterion?: Criterion<CriterionValue>;
} }
export const AddFilter: React.FC<IAddFilterProps> = ( export const AddFilterDialog: React.FC<IAddFilterProps> = ({
props: IAddFilterProps onAddCriterion,
) => { onCancel,
filterOptions,
editingCriterion,
}) => {
const defaultValue = useRef<string | number | undefined>(); const defaultValue = useRef<string | number | undefined>();
const [isOpen, setIsOpen] = useState(false);
const [criterion, setCriterion] = useState<Criterion<CriterionValue>>( const [criterion, setCriterion] = useState<Criterion<CriterionValue>>(
new NoneCriterion() new NoneCriterion()
); );
const { options, modifierOptions } = criterion.criterionOption;
const valueStage = useRef<CriterionValue>(criterion.value); const valueStage = useRef<CriterionValue>(criterion.value);
@@ -50,23 +55,14 @@ export const AddFilter: React.FC<IAddFilterProps> = (
}, },
}); });
// configure keyboard shortcuts
useEffect(() => {
Mousetrap.bind("f", () => setIsOpen(true));
return () => {
Mousetrap.unbind("f");
};
});
// Configure if we are editing an existing criterion // Configure if we are editing an existing criterion
useEffect(() => { useEffect(() => {
if (!props.editingCriterion) { if (!editingCriterion) {
return; setCriterion(makeCriteria());
} else {
setCriterion(editingCriterion);
} }
setIsOpen(true); }, [editingCriterion]);
setCriterion(props.editingCriterion);
}, [props.editingCriterion]);
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) { function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType; const newCriterionType = event.target.value as CriterionType;
@@ -107,38 +103,27 @@ export const AddFilter: React.FC<IAddFilterProps> = (
if (!Array.isArray(criterion.value) && defaultValue.current !== undefined) { if (!Array.isArray(criterion.value) && defaultValue.current !== undefined) {
const value = defaultValue.current; const value = defaultValue.current;
if ( if (
criterion.options && options &&
(value === undefined || value === "" || typeof value === "number") (value === undefined || value === "" || typeof value === "number")
) { ) {
criterion.value = criterion.options[0].toString(); criterion.value = options[0].toString();
} else if (typeof value === "number" && value === undefined) { } else if (typeof value === "number" && value === undefined) {
criterion.value = 0; criterion.value = 0;
} else if (value === undefined) { } else if (value === undefined) {
criterion.value = ""; criterion.value = "";
} }
} }
const oldId = props.editingCriterion const oldId = editingCriterion ? editingCriterion.getId() : undefined;
? props.editingCriterion.getId() onAddCriterion(criterion, oldId);
: undefined;
props.onAddCriterion(criterion, oldId);
onToggle();
}
function onToggle() {
if (isOpen) {
props.onCancel();
}
setIsOpen(!isOpen);
setCriterion(makeCriteria());
} }
const maybeRenderFilterPopoverContents = () => { const maybeRenderFilterPopoverContents = () => {
if (criterion.criterionOption.value === "none") { if (criterion.criterionOption.type === "none") {
return; return;
} }
function renderModifier() { function renderModifier() {
if (criterion.modifierOptions.length === 0) { if (modifierOptions.length === 0) {
return; return;
} }
return ( return (
@@ -148,9 +133,9 @@ export const AddFilter: React.FC<IAddFilterProps> = (
value={criterion.modifier} value={criterion.modifier}
className="btn-secondary" className="btn-secondary"
> >
{criterion.modifierOptions.map((c) => ( {modifierOptions.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
{c.label} {c.label ? intl.formatMessage({ id: c.label }) : ""}
</option> </option>
))} ))}
</Form.Control> </Form.Control>
@@ -168,19 +153,19 @@ export const AddFilter: React.FC<IAddFilterProps> = (
if (Array.isArray(criterion.value)) { if (Array.isArray(criterion.value)) {
if ( if (
criterion.criterionOption.value !== "performers" && criterion.criterionOption.type !== "performers" &&
criterion.criterionOption.value !== "studios" && criterion.criterionOption.type !== "studios" &&
criterion.criterionOption.value !== "parent_studios" && criterion.criterionOption.type !== "parent_studios" &&
criterion.criterionOption.value !== "tags" && criterion.criterionOption.type !== "tags" &&
criterion.criterionOption.value !== "sceneTags" && criterion.criterionOption.type !== "sceneTags" &&
criterion.criterionOption.value !== "performerTags" && criterion.criterionOption.type !== "performerTags" &&
criterion.criterionOption.value !== "movies" criterion.criterionOption.type !== "movies"
) )
return; return;
return ( return (
<FilterSelect <FilterSelect
type={criterion.criterionOption.value} type={criterion.criterionOption.type}
isMulti isMulti
onSelect={(items) => { onSelect={(items) => {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
@@ -195,11 +180,11 @@ export const AddFilter: React.FC<IAddFilterProps> = (
); );
} }
if (criterion instanceof IHierarchicalLabeledIdCriterion) { if (criterion instanceof IHierarchicalLabeledIdCriterion) {
if (criterion.criterionOption.value !== "studios") return; if (criterion.criterionOption.type !== "studios") return;
return ( return (
<FilterSelect <FilterSelect
type={criterion.criterionOption.value} type={criterion.criterionOption.type}
isMulti isMulti
onSelect={(items) => { onSelect={(items) => {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
@@ -213,10 +198,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
/> />
); );
} }
if ( if (options && !criterionIsHierarchicalLabelValue(criterion.value)) {
criterion.options &&
!criterionIsHierarchicalLabelValue(criterion.value)
) {
defaultValue.current = criterion.value; defaultValue.current = criterion.value;
return ( return (
<Form.Control <Form.Control
@@ -225,7 +207,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
value={criterion.value.toString()} value={criterion.value.toString()}
className="btn-secondary" className="btn-secondary"
> >
{criterion.options.map((c) => ( {options.map((c) => (
<option key={c.toString()} value={c.toString()}> <option key={c.toString()} value={c.toString()}>
{c} {c}
</option> </option>
@@ -245,7 +227,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return ( return (
<Form.Control <Form.Control
className="btn-secondary" className="btn-secondary"
type={criterion.inputType} type={criterion.criterionOption.inputType}
onChange={onChangedInput} onChange={onChangedInput}
onBlur={onBlurInput} onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value.toString() : ""} defaultValue={criterion.value ? criterion.value.toString() : ""}
@@ -259,7 +241,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
<Form.Group> <Form.Group>
<Form.Check <Form.Check
checked={criterion.value.depth !== 0} checked={criterion.value.depth !== 0}
label="Include child studios" label={intl.formatMessage({ id: "include_child_studios" })}
onChange={() => { onChange={() => {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
newCriterion.value.depth = newCriterion.value.depth =
@@ -304,7 +286,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
}; };
function maybeRenderFilterCriterion() { function maybeRenderFilterCriterion() {
if (!props.editingCriterion) { if (!editingCriterion) {
return; return;
} }
@@ -312,7 +294,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
<Form.Group> <Form.Group>
<strong> <strong>
{intl.formatMessage({ {intl.formatMessage({
id: props.editingCriterion.criterionOption.messageID, id: editingCriterion.criterionOption.messageID,
})} })}
</strong> </strong>
</Form.Group> </Form.Group>
@@ -320,14 +302,15 @@ export const AddFilter: React.FC<IAddFilterProps> = (
} }
function maybeRenderFilterSelect() { function maybeRenderFilterSelect() {
if (props.editingCriterion) { if (editingCriterion) {
return; return;
} }
const options = props.filterOptions.criterionOptions const thisOptions = [NoneCriterionOption]
.concat(filterOptions.criterionOptions)
.map((c) => { .map((c) => {
return { return {
value: c.value, value: c.type,
text: intl.formatMessage({ id: c.messageID }), text: intl.formatMessage({ id: c.messageID }),
}; };
}) })
@@ -345,10 +328,10 @@ export const AddFilter: React.FC<IAddFilterProps> = (
<Form.Control <Form.Control
as="select" as="select"
onChange={onChangedCriteriaType} onChange={onChangedCriteriaType}
value={criterion.criterionOption.value} value={criterion.criterionOption.type}
className="btn-secondary" className="btn-secondary"
> >
{options.map((c) => ( {thisOptions.map((c) => (
<option key={c.value} value={c.value} disabled={c.value === "none"}> <option key={c.value} value={c.value} disabled={c.value === "none"}>
{c.text} {c.text}
</option> </option>
@@ -358,25 +341,12 @@ export const AddFilter: React.FC<IAddFilterProps> = (
); );
} }
const title = !props.editingCriterion const title = !editingCriterion
? intl.formatMessage({ id: "search_filter.add_filter" }) ? intl.formatMessage({ id: "search_filter.add_filter" })
: intl.formatMessage({ id: "search_filter.update_filter" }); : intl.formatMessage({ id: "search_filter.update_filter" });
return ( return (
<> <>
<OverlayTrigger <Modal show onHide={() => onCancel()}>
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.name" />
</Tooltip>
}
>
<Button variant="secondary" onClick={() => onToggle()} active={isOpen}>
<Icon icon="filter" />
</Button>
</OverlayTrigger>
<Modal show={isOpen} onHide={() => onToggle()}>
<Modal.Header>{title}</Modal.Header> <Modal.Header>{title}</Modal.Header>
<Modal.Body> <Modal.Body>
<div className="dialog-content"> <div className="dialog-content">
@@ -388,7 +358,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
<Modal.Footer> <Modal.Footer>
<Button <Button
onClick={onAddFilter} onClick={onAddFilter}
disabled={criterion.criterionOption.value === "none"} disabled={criterion.criterionOption.type === "none"}
> >
{title} {title}
</Button> </Button>

View File

@@ -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<CriterionValue>[];
onEditCriterion: (c: Criterion<CriterionValue>) => void;
onRemoveCriterion: (c: Criterion<CriterionValue>) => void;
}
export const FilterTags: React.FC<IFilterTagsProps> = ({
criteria,
onEditCriterion,
onRemoveCriterion,
}) => {
const intl = useIntl();
function onRemoveCriterionTag(
criterion: Criterion<CriterionValue>,
$event: React.MouseEvent<HTMLElement, MouseEvent>
) {
if (!criterion) {
return;
}
onRemoveCriterion(criterion);
$event.stopPropagation();
}
function onClickCriterionTag(criterion: Criterion<CriterionValue>) {
onEditCriterion(criterion);
}
function renderFilterTags() {
return criteria.map((criterion) => (
<Badge
className="tag-item"
variant="secondary"
key={criterion.getId()}
onClick={() => onClickCriterionTag(criterion)}
>
{criterion.getLabel(intl)}
<Button
variant="secondary"
onClick={($event) => onRemoveCriterionTag(criterion, $event)}
>
<Icon icon="times" />
</Button>
</Badge>
));
}
return (
<div className="d-flex justify-content-center">{renderFilterTags()}</div>
);
};

View File

@@ -1,9 +1,8 @@
import _, { debounce } from "lodash"; import _, { debounce } from "lodash";
import React, { useState, useEffect } from "react"; import React, { HTMLAttributes, useEffect } from "react";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { SortDirectionEnum } from "src/core/generated-graphql"; import { SortDirectionEnum } from "src/core/generated-graphql";
import { import {
Badge,
Button, Button,
ButtonGroup, ButtonGroup,
Dropdown, Dropdown,
@@ -12,61 +11,44 @@ import {
Tooltip, Tooltip,
InputGroup, InputGroup,
FormControl, FormControl,
ButtonToolbar,
} from "react-bootstrap"; } from "react-bootstrap";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { useFocus } from "src/utils"; import { useFocus } from "src/utils";
import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { import { PersistanceLevel } from "src/hooks/ListHook";
Criterion, import { SavedFilterList } from "./SavedFilterList";
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { AddFilter } from "./AddFilter";
interface IListFilterOperation {
text: string;
onClick: () => void;
isDisplayed?: () => boolean;
}
interface IListFilterProps { interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void; onFilterUpdate: (newFilter: ListFilterModel) => void;
zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void;
onSelectAll?: () => void;
onSelectNone?: () => void;
onEdit?: () => void;
onDelete?: () => void;
otherOperations?: IListFilterOperation[];
filter: ListFilterModel; filter: ListFilterModel;
filterOptions: ListFilterOptions; filterOptions: ListFilterOptions;
itemsSelected?: boolean; filterDialogOpen?: boolean;
persistState?: PersistanceLevel;
openFilterDialog: () => void;
} }
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"];
const minZoom = 0;
const maxZoom = 3;
export const ListFilter: React.FC<IListFilterProps> = ( export const ListFilter: React.FC<IListFilterProps> = ({
props: IListFilterProps onFilterUpdate,
) => { filter,
filterOptions,
filterDialogOpen,
openFilterDialog,
persistState,
}) => {
const [queryRef, setQueryFocus] = useFocus(); const [queryRef, setQueryFocus] = useFocus();
const searchCallback = debounce((value: string) => { const searchCallback = debounce((value: string) => {
const newFilter = _.cloneDeep(props.filter); const newFilter = _.cloneDeep(filter);
newFilter.searchTerm = value; newFilter.searchTerm = value;
newFilter.currentPage = 1; newFilter.currentPage = 1;
props.onFilterUpdate(newFilter); onFilterUpdate(newFilter);
}, 500); }, 500);
const [editingCriterion, setEditingCriterion] = useState<
Criterion<CriterionValue> | undefined
>(undefined);
const intl = useIntl(); const intl = useIntl();
useEffect(() => { useEffect(() => {
@@ -76,81 +58,20 @@ export const ListFilter: React.FC<IListFilterProps> = (
}); });
Mousetrap.bind("r", () => onReshuffleRandomSort()); 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 () => { return () => {
Mousetrap.unbind("/"); Mousetrap.unbind("/");
Mousetrap.unbind("r"); 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<HTMLSelectElement>) { function onChangePageSize(event: React.ChangeEvent<HTMLSelectElement>) {
const val = event.currentTarget.value; const val = event.currentTarget.value;
const newFilter = _.cloneDeep(props.filter); const newFilter = _.cloneDeep(filter);
newFilter.itemsPerPage = parseInt(val, 10); newFilter.itemsPerPage = parseInt(val, 10);
newFilter.currentPage = 1; newFilter.currentPage = 1;
props.onFilterUpdate(newFilter); onFilterUpdate(newFilter);
} }
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) { function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
@@ -158,95 +79,32 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
function onChangeSortDirection() { function onChangeSortDirection() {
const newFilter = _.cloneDeep(props.filter); const newFilter = _.cloneDeep(filter);
if (props.filter.sortDirection === SortDirectionEnum.Asc) { if (filter.sortDirection === SortDirectionEnum.Asc) {
newFilter.sortDirection = SortDirectionEnum.Desc; newFilter.sortDirection = SortDirectionEnum.Desc;
} else { } else {
newFilter.sortDirection = SortDirectionEnum.Asc; newFilter.sortDirection = SortDirectionEnum.Asc;
} }
props.onFilterUpdate(newFilter); onFilterUpdate(newFilter);
} }
function onChangeSortBy(eventKey: string | null) { function onChangeSortBy(eventKey: string | null) {
const newFilter = _.cloneDeep(props.filter); const newFilter = _.cloneDeep(filter);
newFilter.sortBy = eventKey ?? undefined; newFilter.sortBy = eventKey ?? undefined;
newFilter.currentPage = 1; newFilter.currentPage = 1;
props.onFilterUpdate(newFilter); onFilterUpdate(newFilter);
} }
function onReshuffleRandomSort() { function onReshuffleRandomSort() {
const newFilter = _.cloneDeep(props.filter); const newFilter = _.cloneDeep(filter);
newFilter.currentPage = 1; newFilter.currentPage = 1;
newFilter.randomSeed = -1; newFilter.randomSeed = -1;
props.onFilterUpdate(newFilter); onFilterUpdate(newFilter);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = _.cloneDeep(props.filter);
newFilter.displayMode = displayMode;
props.onFilterUpdate(newFilter);
}
function onAddCriterion(
criterion: Criterion<CriterionValue>,
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<CriterionValue>) {
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<CriterionValue>) {
if (!criterion) {
return;
}
setEditingCriterion(undefined);
removedCriterionId = criterion.getId();
onRemoveCriterion(criterion);
}
function onClickCriterionTag(criterion?: Criterion<CriterionValue>) {
if (!criterion || removedCriterionId !== "") {
return;
}
setEditingCriterion(criterion);
} }
function renderSortByOptions() { function renderSortByOptions() {
return props.filterOptions.sortByOptions return filterOptions.sortByOptions
.map((o) => { .map((o) => {
return { return {
message: intl.formatMessage({ id: o.messageID }), message: intl.formatMessage({ id: o.messageID }),
@@ -266,323 +124,134 @@ export const ListFilter: React.FC<IListFilterProps> = (
)); ));
} }
function renderDisplayModeOptions() { const SavedFilterDropdown = React.forwardRef<
function getIcon(option: DisplayMode) { HTMLDivElement,
switch (option) { HTMLAttributes<HTMLDivElement>
case DisplayMode.Grid: >(({ style, className }, ref) => (
return "th-large"; <div ref={ref} style={style} className={className}>
case DisplayMode.List: <SavedFilterList
return "list"; filter={filter}
case DisplayMode.Wall: onSetFilter={(f) => {
return "square"; onFilterUpdate(f);
case DisplayMode.Tagger: }}
return "tags"; persistState={persistState}
} />
} </div>
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) => (
<OverlayTrigger
key={option}
overlay={
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
}
>
<Button
variant="secondary"
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
>
<Icon icon={getIcon(option)} />
</Button>
</OverlayTrigger>
));
}
function renderFilterTags() {
return props.filter.criteria.map((criterion) => (
<Badge
className="tag-item"
variant="secondary"
key={criterion.getId()}
onClick={() => onClickCriterionTag(criterion)}
>
{criterion.getLabel(intl)}
<Button
variant="secondary"
onClick={() => onRemoveCriterionTag(criterion)}
>
<Icon icon="times" />
</Button>
</Badge>
));
}
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 (
<Dropdown.Item
key="select-all"
className="bg-secondary text-white"
onClick={() => onSelectAll()}
>
<FormattedMessage id="actions.select_all" />
</Dropdown.Item>
);
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return (
<Dropdown.Item
key="select-none"
className="bg-secondary text-white"
onClick={() => onSelectNone()}
>
<FormattedMessage id="actions.select_none" />
</Dropdown.Item>
);
}
}
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(
<Dropdown.Item
key={o.text}
className="bg-secondary text-white"
onClick={o.onClick}
>
{o.text}
</Dropdown.Item>
);
});
}
if (options.length > 0) {
return (
<Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon="ellipsis-h" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{options}
</Dropdown.Menu>
</Dropdown>
);
}
}
function onChangeZoom(v: number) {
if (props.onChangeZoom) {
props.onChangeZoom(v);
}
}
function maybeRenderZoom() {
if (props.onChangeZoom && props.filter.displayMode === DisplayMode.Grid) {
return (
<div className="align-middle">
<Form.Control
className="zoom-slider d-none d-sm-inline-flex ml-3"
type="range"
min={minZoom}
max={maxZoom}
value={props.zoomIndex}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChangeZoom(Number.parseInt(e.currentTarget.value, 10))
}
/>
</div>
);
}
}
function maybeRenderSelectedButtons() {
if (props.itemsSelected && (props.onEdit || props.onDelete)) {
return (
<ButtonGroup className="ml-2">
{props.onEdit && (
<OverlayTrigger
overlay={
<Tooltip id="edit">
{intl.formatMessage({ id: "actions.edit" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
</Button>
</OverlayTrigger>
)}
{props.onDelete && (
<OverlayTrigger
overlay={
<Tooltip id="delete">
{intl.formatMessage({ id: "actions.delete" })}
</Tooltip>
}
>
<Button variant="danger" onClick={onDelete}>
<Icon icon="trash" />
</Button>
</OverlayTrigger>
)}
</ButtonGroup>
);
}
}
function render() { function render() {
const currentSortBy = props.filterOptions.sortByOptions.find( const currentSortBy = filterOptions.sortByOptions.find(
(o) => o.value === props.filter.sortBy (o) => o.value === filter.sortBy
); );
return ( return (
<> <>
<ButtonToolbar className="align-items-center justify-content-center mb-2"> <div className="d-flex mb-1">
<div className="d-flex"> <InputGroup className="mr-2 flex-grow-1">
<InputGroup className="mr-2 flex-grow-1"> <InputGroup.Prepend>
<FormControl <Dropdown>
ref={queryRef}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
defaultValue={props.filter.searchTerm}
onInput={onChangeQuery}
className="bg-secondary text-white border-secondary w-50"
/>
<InputGroup.Append>
<AddFilter
filterOptions={props.filterOptions}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
/>
</InputGroup.Append>
</InputGroup>
<Dropdown as={ButtonGroup} className="mr-2">
<Dropdown.Toggle split variant="secondary" id="more-menu">
{currentSortBy
? intl.formatMessage({ id: currentSortBy.messageID })
: ""}
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === SortDirectionEnum.Asc
? intl.formatMessage({ id: "ascending" })
: intl.formatMessage({ id: "descending" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
props.filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
}
/>
</Button>
</OverlayTrigger>
{props.filter.sortBy === "random" && (
<OverlayTrigger <OverlayTrigger
placement="top"
overlay={ overlay={
<Tooltip id="sort-reshuffle-tooltip"> <Tooltip id="filter-tooltip">
{intl.formatMessage({ id: "actions.reshuffle" })} <FormattedMessage id="search_filter.saved_filters" />
</Tooltip> </Tooltip>
} }
> >
<Button variant="secondary" onClick={onReshuffleRandomSort}> <Dropdown.Toggle variant="secondary">
<Icon icon="random" /> <Icon icon="bookmark" />
</Button> </Dropdown.Toggle>
</OverlayTrigger> </OverlayTrigger>
)} <Dropdown.Menu
</Dropdown> as={SavedFilterDropdown}
</div> className="saved-filter-list-menu"
/>
</Dropdown>
</InputGroup.Prepend>
<FormControl
ref={queryRef}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
defaultValue={filter.searchTerm}
onInput={onChangeQuery}
className="query-text-field bg-secondary text-white border-secondary"
/>
<Form.Control <InputGroup.Append>
as="select" <OverlayTrigger
onChange={onChangePageSize} placement="top"
value={props.filter.itemsPerPage.toString()} overlay={
className="btn-secondary mx-1" <Tooltip id="filter-tooltip">
> <FormattedMessage id="search_filter.name" />
{PAGE_SIZE_OPTIONS.map((s) => ( </Tooltip>
<option value={s} key={s}> }
{s} >
</option> <Button
))} variant="secondary"
</Form.Control> onClick={() => openFilterDialog()}
active={filterDialogOpen}
{maybeRenderSelectedButtons()} >
<Icon icon="filter" />
<div className="mx-2">{renderMore()}</div> </Button>
</OverlayTrigger>
<ButtonGroup>{renderDisplayModeOptions()}</ButtonGroup> </InputGroup.Append>
{maybeRenderZoom()} </InputGroup>
</ButtonToolbar>
<div className="d-flex justify-content-center">
{renderFilterTags()}
</div> </div>
<Dropdown as={ButtonGroup} className="mr-2 mb-1">
<Dropdown.Toggle variant="secondary">
{currentSortBy
? intl.formatMessage({ id: currentSortBy.messageID })
: ""}
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{filter.sortDirection === SortDirectionEnum.Asc
? intl.formatMessage({ id: "ascending" })
: intl.formatMessage({ id: "descending" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
}
/>
</Button>
</OverlayTrigger>
{filter.sortBy === "random" && (
<OverlayTrigger
overlay={
<Tooltip id="sort-reshuffle-tooltip">
{intl.formatMessage({ id: "actions.reshuffle" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onReshuffleRandomSort}>
<Icon icon="random" />
</Button>
</OverlayTrigger>
)}
</Dropdown>
<Form.Control
as="select"
onChange={onChangePageSize}
value={filter.itemsPerPage.toString()}
className="btn-secondary mx-1 mb-1"
>
{PAGE_SIZE_OPTIONS.map((s) => (
<option value={s} key={s}>
{s}
</option>
))}
</Form.Control>
</> </>
); );
} }

View File

@@ -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<IListOperationButtonsProps> = ({
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 (
<ButtonGroup className="ml-2 mb-1">
{onEdit && (
<OverlayTrigger
overlay={
<Tooltip id="edit">
{intl.formatMessage({ id: "actions.edit" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
</Button>
</OverlayTrigger>
)}
{onDelete && (
<OverlayTrigger
overlay={
<Tooltip id="delete">
{intl.formatMessage({ id: "actions.delete" })}
</Tooltip>
}
>
<Button variant="danger" onClick={onDelete}>
<Icon icon="trash" />
</Button>
</OverlayTrigger>
)}
</ButtonGroup>
);
}
}
function renderSelectAll() {
if (onSelectAll) {
return (
<Dropdown.Item
key="select-all"
className="bg-secondary text-white"
onClick={() => onSelectAll?.()}
>
<FormattedMessage id="actions.select_all" />
</Dropdown.Item>
);
}
}
function renderSelectNone() {
if (onSelectNone) {
return (
<Dropdown.Item
key="select-none"
className="bg-secondary text-white"
onClick={() => onSelectNone?.()}
>
<FormattedMessage id="actions.select_none" />
</Dropdown.Item>
);
}
}
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(
<Dropdown.Item
key={o.text}
className="bg-secondary text-white"
onClick={o.onClick}
>
{o.text}
</Dropdown.Item>
);
});
}
if (options.length > 0) {
return (
<Dropdown className="mb-1">
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon="ellipsis-h" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{options}
</Dropdown.Menu>
</Dropdown>
);
}
}
return (
<>
{maybeRenderSelectedButtons()}
<div className="mx-2">{renderMore()}</div>
</>
);
};

View File

@@ -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<IListViewOptionsProps> = ({
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 (
<ButtonGroup>
{displayModeOptions.map((option) => (
<OverlayTrigger
key={option}
overlay={
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
}
>
<Button
variant="secondary"
active={displayMode === option}
onClick={() => onSetDisplayMode(option)}
>
<Icon icon={getIcon(option)} />
</Button>
</OverlayTrigger>
))}
</ButtonGroup>
);
}
function onChangeZoom(v: number) {
if (onSetZoom) {
onSetZoom(v);
}
}
function maybeRenderZoom() {
if (onSetZoom && displayMode === DisplayMode.Grid) {
return (
<div className="align-middle">
<Form.Control
className="zoom-slider d-none d-sm-inline-flex ml-3"
type="range"
min={minZoom}
max={maxZoom}
value={zoomIndex}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChangeZoom(Number.parseInt(e.currentTarget.value, 10))
}
/>
</div>
);
}
}
return (
<>
<ButtonGroup>{maybeRenderDisplayModeOptions()}</ButtonGroup>
{maybeRenderZoom()}
</>
);
};

View File

@@ -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<ISavedFilterListProps> = ({
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<ISavedFilterItem> = ({ item }) => {
return (
<div className="dropdown-item-container">
<Dropdown.Item onClick={() => filterClicked(item)} title={item.name}>
<span>{item.name}</span>
</Dropdown.Item>
<ButtonGroup>
<Button
className="save-button"
variant="secondary"
size="sm"
title={intl.formatMessage({ id: "actions.overwrite" })}
onClick={(e) => {
setOverwritingFilter(item);
e.stopPropagation();
}}
>
<Icon icon="save" />
</Button>
<Button
className="delete-button"
variant="secondary"
size="sm"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={(e) => {
setDeletingFilter(item);
e.stopPropagation();
}}
>
<Icon icon="times" />
</Button>
</ButtonGroup>
</div>
);
};
function maybeRenderDeleteAlert() {
if (!deletingFilter) {
return;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.delete_confirm"
values={{
entityName: deletingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="danger"
onClick={() => onDeleteFilter(deletingFilter)}
>
{intl.formatMessage({ id: "actions.delete" })}
</Button>
<Button
variant="secondary"
onClick={() => setDeletingFilter(undefined)}
>
{intl.formatMessage({ id: "actions.cancel" })}
</Button>
</Modal.Footer>
</Modal>
);
}
function maybeRenderOverwriteAlert() {
if (!overwritingFilter) {
return;
}
return (
<Modal show>
<Modal.Body>
<FormattedMessage
id="dialogs.overwrite_filter_confirm"
values={{
entityName: overwritingFilter.name,
}}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
onClick={() =>
onSaveFilter(overwritingFilter.name, overwritingFilter.id)
}
>
{intl.formatMessage({ id: "actions.overwrite" })}
</Button>
<Button
variant="secondary"
onClick={() => setOverwritingFilter(undefined)}
>
{intl.formatMessage({ id: "actions.cancel" })}
</Button>
</Modal.Footer>
</Modal>
);
}
function renderSavedFilters() {
if (loading || saving) {
return (
<div className="loading">
<LoadingIndicator message="" />
</div>
);
}
return (
<ul className="saved-filter-list">
{savedFilters
.filter(
(f) => !filterName || f.name.toLowerCase().includes(filterName)
)
.map((f) => (
<SavedFilterItem key={f.name} item={f} />
))}
</ul>
);
}
function maybeRenderSetDefaultButton() {
if (persistState === PersistanceLevel.ALL) {
return (
<Button
className="set-as-default-button"
variant="secondary"
size="sm"
onClick={() => onSetDefaultFilter()}
>
{intl.formatMessage({ id: "actions.set_as_default" })}
</Button>
);
}
}
return (
<div>
{maybeRenderDeleteAlert()}
{maybeRenderOverwriteAlert()}
<InputGroup>
<FormControl
className="bg-secondary text-white border-secondary"
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
/>
<InputGroup.Append>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="actions.save_filter" />
</Tooltip>
}
>
<Button
disabled={
!filterName || !!savedFilters.find((f) => f.name === filterName)
}
variant="secondary"
onClick={() => {
onSaveFilter(filterName);
}}
>
<Icon icon="save" />
</Button>
</OverlayTrigger>
</InputGroup.Append>
</InputGroup>
{renderSavedFilters()}
{maybeRenderSetDefaultButton()}
</div>
);
};

View File

@@ -24,3 +24,54 @@ input[type="range"].zoom-slider {
padding-left: 0; padding-left: 0;
padding-right: 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;
}
}
}

View File

@@ -13,7 +13,7 @@ export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
const movieValue = { id: movie.id!, label: movie.name! }; const movieValue = { id: movie.id!, label: movie.name! };
// if movie is already present, then we modify it, otherwise add // if movie is already present, then we modify it, otherwise add
let movieCriterion = filter.criteria.find((c) => { let movieCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "movies"; return c.criterionOption.type === "movies";
}) as MoviesCriterion; }) as MoviesCriterion;
if ( if (

View File

@@ -2,10 +2,10 @@ import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { TagLink } from "src/components/Shared"; import { TagLink } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { genderToString } from "src/utils/gender";
interface IPerformerDetails { interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>; performer: Partial<GQL.PerformerDataFragment>;

View File

@@ -14,10 +14,7 @@ import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
import { import {
getGenderStrings,
useListPerformerScrapers, useListPerformerScrapers,
genderToString,
stringToGender,
queryScrapePerformer, queryScrapePerformer,
mutateReloadScrapers, mutateReloadScrapers,
usePerformerUpdate, usePerformerUpdate,
@@ -40,6 +37,11 @@ import { useToast } from "src/hooks";
import { Prompt, useHistory } from "react-router-dom"; import { Prompt, useHistory } from "react-router-dom";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import {
genderStrings,
genderToString,
stringToGender,
} from "src/utils/gender";
import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerScrapeModal from "./PerformerScrapeModal";
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
@@ -92,7 +94,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();
const genderOptions = [""].concat(getGenderStrings()); const genderOptions = [""].concat(genderStrings);
const labelXS = 3; const labelXS = 3;
const labelXL = 2; const labelXL = 2;

View File

@@ -9,23 +9,23 @@ import {
ScrapeDialogRow, ScrapeDialogRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog";
import { import { useTagCreate } from "src/core/StashService";
getGenderStrings,
genderToString,
stringToGender,
useTagCreate,
} from "src/core/StashService";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { TagSelect } from "src/components/Shared"; import { TagSelect } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import _ from "lodash"; import _ from "lodash";
import {
genderStrings,
genderToString,
stringToGender,
} from "src/utils/gender";
function renderScrapedGender( function renderScrapedGender(
result: ScrapeResult<string>, result: ScrapeResult<string>,
isNew?: boolean, isNew?: boolean,
onChange?: (value: string) => void onChange?: (value: string) => void
) { ) {
const selectOptions = [""].concat(getGenderStrings()); const selectOptions = [""].concat(genderStrings);
return ( return (
<Form.Control <Form.Control

View File

@@ -233,7 +233,7 @@ export const Scene: React.FC = () => {
return; return;
} }
const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); const filterCopy = sceneQueue.query.clone();
const newStart = queueStart - filterCopy.itemsPerPage; const newStart = queueStart - filterCopy.itemsPerPage;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy); const query = await queryFindScenes(filterCopy);
@@ -254,7 +254,7 @@ export const Scene: React.FC = () => {
return; return;
} }
const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); const filterCopy = sceneQueue.query.clone();
const newStart = queueStart + queueScenes.length; const newStart = queueStart + queueScenes.length;
filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);
const query = await queryFindScenes(filterCopy); const query = await queryFindScenes(filterCopy);
@@ -291,7 +291,7 @@ export const Scene: React.FC = () => {
const pages = Math.ceil(queueTotal / query.itemsPerPage); const pages = Math.ceil(queueTotal / query.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1; const page = Math.floor(Math.random() * pages) + 1;
const index = Math.floor(Math.random() * query.itemsPerPage); const index = Math.floor(Math.random() * query.itemsPerPage);
const filterCopy = Object.assign(new ListFilterModel(), sceneQueue.query); const filterCopy = sceneQueue.query.clone();
filterCopy.currentPage = page; filterCopy.currentPage = page;
const queryResults = await queryFindScenes(filterCopy); const queryResults = await queryFindScenes(filterCopy);
if (queryResults.data.findScenes.scenes.length > index) { if (queryResults.data.findScenes.scenes.length > index) {

View File

@@ -15,7 +15,7 @@ export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
const studioValue = { id: studio.id!, label: studio.name! }; const studioValue = { id: studio.id!, label: studio.name! };
// if studio is already present, then we modify it, otherwise add // if studio is already present, then we modify it, otherwise add
let parentStudioCriterion = filter.criteria.find((c) => { let parentStudioCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "parent_studios"; return c.criterionOption.type === "parent_studios";
}) as ParentStudiosCriterion; }) as ParentStudiosCriterion;
if ( if (

View File

@@ -11,8 +11,8 @@ import {
TruncatedText, TruncatedText,
} from "src/components/Shared"; } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { genderToString } from "src/utils/gender";
import { IStashBoxPerformer } from "./utils"; import { IStashBoxPerformer } from "./utils";
interface IPerformerModalProps { interface IPerformerModalProps {

View File

@@ -16,7 +16,7 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
const tagValue = { id: tag.id!, label: tag.name! }; const tagValue = { id: tag.id!, label: tag.name! };
// if tag is already present, then we modify it, otherwise add // if tag is already present, then we modify it, otherwise add
let tagCriterion = filter.criteria.find((c) => { let tagCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "tags"; return c.criterionOption.type === "tags";
}) as TagsCriterion; }) as TagsCriterion;
if ( if (

View File

@@ -5,6 +5,7 @@ import {
getQueryDefinition, getQueryDefinition,
getOperationName, getOperationName,
} from "@apollo/client/utilities"; } from "@apollo/client/utilities";
import { stringToGender } from "src/utils/gender";
import { filterData } from "../utils"; import { filterData } from "../utils";
import { ListFilterModel } from "../models/list-filter/filter"; import { ListFilterModel } from "../models/list-filter/filter";
import * as GQL from "./generated-graphql"; 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) => export const useFindGalleries = (filter: ListFilterModel) =>
GQL.useFindGalleriesQuery({ GQL.useFindGalleriesQuery({
variables: { variables: {
@@ -680,6 +695,29 @@ export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) =>
update: deleteCache(tagMutationImpactedQueries), 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 = () => export const useTagsMerge = () =>
GQL.useTagsMergeMutation({ GQL.useTagsMergeMutation({
update: deleteCache(tagMutationImpactedQueries), update: deleteCache(tagMutationImpactedQueries),
@@ -973,54 +1011,6 @@ export const queryParseSceneFilenames = (
fetchPolicy: "network-only", fetchPolicy: "network-only",
}); });
export const stringGenderMap = new Map<string, GQL.GenderEnum>([
["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 = ( export const makePerformerCreateInput = (
toCreate: GQL.ScrapedScenePerformer toCreate: GQL.ScrapedScenePerformer
) => { ) => {

View File

@@ -9,7 +9,7 @@ export const performerFilterHook = (
const performerValue = { id: performer.id!, label: performer.name! }; const performerValue = { id: performer.id!, label: performer.name! };
// if performers is already present, then we modify it, otherwise add // if performers is already present, then we modify it, otherwise add
let performerCriterion = filter.criteria.find((c) => { let performerCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "performers"; return c.criterionOption.type === "performers";
}) as PerformersCriterion; }) as PerformersCriterion;
if ( if (

View File

@@ -7,7 +7,7 @@ export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => {
const studioValue = { id: studio.id!, label: studio.name! }; const studioValue = { id: studio.id!, label: studio.name! };
// if studio is already present, then we modify it, otherwise add // if studio is already present, then we modify it, otherwise add
let studioCriterion = filter.criteria.find((c) => { let studioCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "studios"; return c.criterionOption.type === "studios";
}) as StudiosCriterion; }) as StudiosCriterion;
if ( if (

View File

@@ -10,7 +10,7 @@ export const tagFilterHook = (tag: GQL.TagDataFragment) => {
const tagValue = { id: tag.id, label: tag.name }; const tagValue = { id: tag.id, label: tag.name };
// if tag is already present, then we modify it, otherwise add // if tag is already present, then we modify it, otherwise add
let tagCriterion = filter.criteria.find((c) => { let tagCriterion = filter.criteria.find((c) => {
return c.criterionOption.value === "tags"; return c.criterionOption.type === "tags";
}) as TagsCriterion; }) as TagsCriterion;
if ( if (

View File

@@ -27,12 +27,15 @@ import {
TagDataFragment, TagDataFragment,
FindImagesQueryResult, FindImagesQueryResult,
SlimImageDataFragment, SlimImageDataFragment,
FilterMode,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { useInterfaceLocalForage } from "src/hooks/LocalForage"; import { useInterfaceLocalForage } from "src/hooks/LocalForage";
import { LoadingIndicator } from "src/components/Shared"; import { LoadingIndicator } from "src/components/Shared";
import { ListFilter } from "src/components/List/ListFilter"; import { ListFilter } from "src/components/List/ListFilter";
import { FilterTags } from "src/components/List/FilterTags";
import { Pagination, PaginationIndex } from "src/components/List/Pagination"; import { Pagination, PaginationIndex } from "src/components/List/Pagination";
import { import {
useFindDefaultFilter,
useFindScenes, useFindScenes,
useFindSceneMarkers, useFindSceneMarkers,
useFindImages, useFindImages,
@@ -43,9 +46,17 @@ import {
useFindTags, useFindTags,
} from "src/core/StashService"; } from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter"; 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 { ListFilterOptions } from "src/models/list-filter/filter-options";
import { getFilterOptions } from "src/models/list-filter/factory"; 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 = <I extends IDataItem>( const getSelectedData = <I extends IDataItem>(
result: I[], result: I[],
@@ -88,8 +99,11 @@ export interface IListHookOperation<T> {
} }
export enum PersistanceLevel { export enum PersistanceLevel {
// do not load default query or persist display mode
NONE, NONE,
// load default query, don't load or persist display mode
ALL, ALL,
// load and persist display mode only
VIEW, VIEW,
} }
@@ -98,6 +112,10 @@ interface IListHookOptions<T, E> {
persistanceKey?: string; persistanceKey?: string;
defaultSort?: string; defaultSort?: string;
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
filterDialog?: (
criteria: Criterion<CriterionValue>[],
setCriteria: (v: Criterion<CriterionValue>[]) => void
) => React.ReactNode;
zoomable?: boolean; zoomable?: boolean;
selectable?: boolean; selectable?: boolean;
defaultZoomIndex?: number; defaultZoomIndex?: number;
@@ -167,6 +185,8 @@ const RenderList = <
renderEditDialog, renderEditDialog,
renderDeleteDialog, renderDeleteDialog,
updateQueryParams, updateQueryParams,
filterDialog,
persistState,
}: IListHookOptions<QueryResult, QueryData> & }: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData> & IQuery<QueryResult, QueryData> &
IRenderListProps) => { IRenderListProps) => {
@@ -176,6 +196,11 @@ const RenderList = <
const [lastClickedId, setLastClickedId] = useState<string | undefined>(); const [lastClickedId, setLastClickedId] = useState<string | undefined>();
const [zoomIndex, setZoomIndex] = useState<number>(defaultZoomIndex ?? 1); const [zoomIndex, setZoomIndex] = useState<number>(defaultZoomIndex ?? 1);
const [editingCriterion, setEditingCriterion] = useState<
Criterion<CriterionValue> | undefined
>(undefined);
const [newCriterion, setNewCriterion] = useState(false);
const result = useData(filter); const result = useData(filter);
const totalCount = getCount(result); const totalCount = getCount(result);
const items = getData(result); const items = getData(result);
@@ -189,6 +214,7 @@ const RenderList = <
}, [pages, filter.currentPage, onChangePage]); }, [pages, filter.currentPage, onChangePage]);
useEffect(() => { useEffect(() => {
Mousetrap.bind("f", () => setNewCriterion(true));
Mousetrap.bind("right", () => { Mousetrap.bind("right", () => {
const maxPage = totalCount / filter.itemsPerPage; const maxPage = totalCount / filter.itemsPerPage;
if (filter.currentPage < maxPage) { 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<CriterionValue>,
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<CriterionValue>) {
const newFilter = _.cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateQueryParams(newFilter);
}
function updateCriteria(c: Criterion<CriterionValue>[]) {
const newFilter = _.cloneDeep(filter);
newFilter.criteria = c.slice();
setNewCriterion(false);
}
function onCancelAddCriterion() {
setEditingCriterion(undefined);
setNewCriterion(false);
}
const content = ( const content = (
<div> <div>
<ListFilter <ButtonToolbar className="align-items-center justify-content-center mb-2">
onFilterUpdate={updateQueryParams} <ListFilter
onSelectAll={selectable ? onSelectAll : undefined} onFilterUpdate={updateQueryParams}
onSelectNone={selectable ? onSelectNone : undefined} filter={filter}
zoomIndex={zoomable ? zoomIndex : undefined} filterOptions={filterOptions}
onChangeZoom={zoomable ? onChangeZoom : undefined} openFilterDialog={() => setNewCriterion(true)}
otherOperations={operations} filterDialogOpen={newCriterion ?? editingCriterion}
itemsSelected={selectedIds.size > 0} persistState={persistState}
onEdit={renderEditDialog ? onEdit : undefined} />
onDelete={renderDeleteDialog ? onDelete : undefined} <ListOperationButtons
filter={filter} onSelectAll={selectable ? onSelectAll : undefined}
filterOptions={filterOptions} onSelectNone={selectable ? onSelectNone : undefined}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
zoomIndex={zoomable ? zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
/>
</ButtonToolbar>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => setEditingCriterion(c)}
onRemoveCriterion={onRemoveCriterion}
/> />
{(newCriterion || editingCriterion) && !filterDialog && (
<AddFilterDialog
filterOptions={filterOptions}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
/>
)}
{newCriterion &&
filterDialog &&
filterDialog(filter.criteria, (c) => updateCriteria(c))}
{isEditDialogOpen && {isEditDialogOpen &&
renderEditDialog && renderEditDialog &&
renderEditDialog( renderEditDialog(
@@ -454,6 +563,7 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const defaultDisplayMode = filterOptions.displayModeOptions[0]; const defaultDisplayMode = filterOptions.displayModeOptions[0];
const [filter, setFilter] = useState<ListFilterModel>( const [filter, setFilter] = useState<ListFilterModel>(
new ListFilterModel( new ListFilterModel(
options.filterMode,
queryString.parse(location.search), queryString.parse(location.search),
defaultSort, defaultSort,
defaultDisplayMode defaultDisplayMode
@@ -462,8 +572,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const updateInterfaceConfig = useCallback( const updateInterfaceConfig = useCallback(
(updatedFilter: ListFilterModel, level: PersistanceLevel) => { (updatedFilter: ListFilterModel, level: PersistanceLevel) => {
setInterfaceState((prevState) => { if (level === PersistanceLevel.VIEW) {
if (level === PersistanceLevel.VIEW) { setInterfaceState((prevState) => {
return { return {
[persistanceKey]: { [persistanceKey]: {
...prevState[persistanceKey], ...prevState[persistanceKey],
@@ -473,84 +583,16 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
}), }),
}, },
}; };
} });
return { }
[persistanceKey]: {
filter: updatedFilter.makeQueryParameters(),
itemsPerPage: updatedFilter.itemsPerPage,
currentPage: updatedFilter.currentPage,
},
};
});
}, },
[persistanceKey, setInterfaceState] [persistanceKey, setInterfaceState]
); );
useEffect(() => { const {
if ( data: defaultFilter,
interfaceState.loading || loading: defaultFilterLoading,
// Only update query params on page the hook was mounted on } = useFindDefaultFilter(options.filterMode);
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 updateQueryParams = useCallback( const updateQueryParams = useCallback(
(listFilter: ListFilterModel) => { (listFilter: ListFilterModel) => {
@@ -565,6 +607,67 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
[setFilter, history, location, options.persistState, updateInterfaceConfig] [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( const onChangePage = useCallback(
(page: number) => { (page: number) => {
const newFilter = _.cloneDeep(filter); const newFilter = _.cloneDeep(filter);

View File

@@ -58,6 +58,7 @@
"reshuffle": "Reshuffle", "reshuffle": "Reshuffle",
"running": "running", "running": "running",
"save": "Save", "save": "Save",
"save_filter": "Save filter",
"scan": "Scan", "scan": "Scan",
"scrape_with": "Scrape with…", "scrape_with": "Scrape with…",
"search": "Search", "search": "Search",
@@ -65,6 +66,7 @@
"select_none": "Select None", "select_none": "Select None",
"selective_auto_tag": "Selective Auto Tag", "selective_auto_tag": "Selective Auto Tag",
"selective_scan": "Selective Scan", "selective_scan": "Selective Scan",
"set_as_default": "Set as default",
"set_back_image": "Back image…", "set_back_image": "Back image…",
"set_front_image": "Front image…", "set_front_image": "Front image…",
"set_image": "Set image…", "set_image": "Set image…",
@@ -400,6 +402,7 @@
"destination": "Destination", "destination": "Destination",
"source": "Source" "source": "Source"
}, },
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
"scene_gen": { "scene_gen": {
"image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)", "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)", "markers": "Markers (20 second videos which begin at the given timecode)",
@@ -477,6 +480,9 @@
"file_info": "File Info", "file_info": "File Info",
"file_mod_time": "File Modification Time", "file_mod_time": "File Modification Time",
"filesize": "File Size", "filesize": "File Size",
"filter": "Filter",
"filter_name": "Filter name",
"filters": "Filters",
"framerate": "Frame Rate", "framerate": "Frame Rate",
"galleries": "Galleries", "galleries": "Galleries",
"gallery": "Gallery", "gallery": "Gallery",
@@ -490,6 +496,7 @@
"image_count": "Image Count", "image_count": "Image Count",
"images": "Images", "images": "Images",
"images-size": "Images size", "images-size": "Images size",
"include_child_studios": "Include child studios",
"instagram": "Instagram", "instagram": "Instagram",
"interactive": "Interactive", "interactive": "Interactive",
"isMissing": "Is Missing", "isMissing": "Is Missing",
@@ -552,6 +559,7 @@
"search_filter": { "search_filter": {
"add_filter": "Add Filter", "add_filter": "Add Filter",
"name": "Filter", "name": "Filter",
"saved_filters": "Saved filters",
"update_filter": "Update Filter" "update_filter": "Update Filter"
}, },
"seconds": "Seconds", "seconds": "Seconds",
@@ -570,12 +578,15 @@
"toast": { "toast": {
"added_entity": "Added {entity}", "added_entity": "Added {entity}",
"added_generation_job_to_queue": "Added generation job to queue", "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_entity": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"generating_screenshot": "Generating screenshot…", "generating_screenshot": "Generating screenshot…",
"merged_tags": "Merged tags", "merged_tags": "Merged tags",
"rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
"started_auto_tagging": "Started auto tagging", "started_auto_tagging": "Started auto tagging",
"saved_entity": "Saved {entity}",
"updated_entity": "Updated {entity}" "updated_entity": "Updated {entity}"
}, },
"total": "Total", "total": "Total",

View File

@@ -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 { export class CountryCriterion extends StringCriterion {
constructor() { constructor() {

View File

@@ -16,106 +16,58 @@ import {
IHierarchicalLabelValue, IHierarchicalLabelValue,
} from "../types"; } from "../types";
type Option = string | number | IOptionType; export type Option = string | number | IOptionType;
export type CriterionValue = export type CriterionValue =
| string | string
| number | number
| ILabeledId[] | ILabeledId[]
| IHierarchicalLabelValue; | 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 // V = criterion value type
export abstract class Criterion<V extends CriterionValue> { export abstract class Criterion<V extends CriterionValue> {
public static getModifierOption( public static getModifierOption(
modifier: CriterionModifier = CriterionModifier.Equals modifier: CriterionModifier = CriterionModifier.Equals
): ILabeledValue { ): ILabeledValue {
switch (modifier) { const messageID = modifierMessageIDs[modifier];
case CriterionModifier.Equals: return { value: modifier, label: messageID };
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",
};
}
} }
public criterionOption: CriterionOption; public criterionOption: CriterionOption;
public abstract modifier: CriterionModifier; public modifier: CriterionModifier;
public abstract modifierOptions: ILabeledValue[]; public value: V;
public abstract options: Option[] | undefined;
public abstract value: V;
public inputType: "number" | "text" | undefined;
public abstract getLabelValue(): string; public abstract getLabelValue(): string;
constructor(type: CriterionOption) { constructor(type: CriterionOption, value: V) {
this.criterionOption = type; 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 { public getLabel(intl: IntlShape): string {
let modifierMessageID: string; const modifierString = Criterion.getModifierLabel(intl, this.modifier);
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 })
: "";
let valueString = ""; let valueString = "";
if ( if (
@@ -145,7 +97,7 @@ export abstract class Criterion<V extends CriterionValue> {
public toJSON() { public toJSON() {
const encodedCriterion = { const encodedCriterion = {
type: this.criterionOption.value, type: this.criterionOption.type,
// #394 - the presence of a # symbol results in the query URL being // #394 - the presence of a # symbol results in the query URL being
// malformed. We could set encode: true in the queryString.stringify // malformed. We could set encode: true in the queryString.stringify
// call below, but this results in a URL that gets pretty long and ugly. // call below, but this results in a URL that gets pretty long and ugly.
@@ -171,37 +123,72 @@ export abstract class Criterion<V extends CriterionValue> {
} }
} }
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 { export class CriterionOption {
public readonly messageID: string; public readonly messageID: string;
public readonly value: CriterionType; public readonly type: CriterionType;
public readonly parameterName: string; 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) { constructor(options: ICriterionOptionsParams) {
this.messageID = messageID; this.messageID = options.messageID;
this.value = value; this.type = options.type;
this.parameterName = parameterName ?? value; 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) { export class StringCriterionOption extends CriterionOption {
return new CriterionOption(value, value); 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<string> { export class StringCriterion extends Criterion<string> {
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() { public getLabelValue() {
return this.value; return this.value;
} }
@@ -218,74 +205,125 @@ export class StringCriterion extends Criterion<string> {
return str.replaceAll(c, encodeURIComponent(c)); return str.replaceAll(c, encodeURIComponent(c));
} }
constructor(type: CriterionOption, options?: string[]) { constructor(type: CriterionOption) {
super(type); super(type, "");
this.options = options;
this.inputType = "text";
} }
} }
export class MandatoryStringCriterion extends StringCriterion { export class MandatoryStringCriterionOption extends CriterionOption {
public modifierOptions = [ constructor(
StringCriterion.getModifierOption(CriterionModifier.Equals), messageID: string,
StringCriterion.getModifierOption(CriterionModifier.NotEquals), value: CriterionType,
StringCriterion.getModifierOption(CriterionModifier.Includes), parameterName?: string,
StringCriterion.getModifierOption(CriterionModifier.Excludes), options?: Option[]
StringCriterion.getModifierOption(CriterionModifier.MatchesRegex), ) {
StringCriterion.getModifierOption(CriterionModifier.NotMatchesRegex), 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 { export class BooleanCriterion extends StringCriterion {
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
constructor(type: CriterionOption) {
super(type, [true.toString(), false.toString()]);
}
protected toCriterionInput(): boolean { protected toCriterionInput(): boolean {
return this.value === "true"; return this.value === "true";
} }
} }
export class NumberCriterion extends Criterion<number> { export class NumberCriterionOption extends CriterionOption {
public modifier = CriterionModifier.Equals; constructor(
public modifierOptions = [ messageID: string,
Criterion.getModifierOption(CriterionModifier.Equals), value: CriterionType,
Criterion.getModifierOption(CriterionModifier.NotEquals), parameterName?: string,
Criterion.getModifierOption(CriterionModifier.GreaterThan), options?: Option[]
Criterion.getModifierOption(CriterionModifier.LessThan), ) {
Criterion.getModifierOption(CriterionModifier.IsNull), super({
Criterion.getModifierOption(CriterionModifier.NotNull), messageID,
]; type: value,
public options: number[] | undefined; parameterName,
public value: number = 0; 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<number> {
public getLabelValue() { public getLabelValue() {
return this.value.toString(); return this.value.toString();
} }
constructor(type: CriterionOption, options?: number[]) { constructor(type: CriterionOption) {
super(type); super(type, 0);
this.options = options;
this.inputType = "number";
} }
} }
export abstract class ILabeledIdCriterion extends Criterion<ILabeledId[]> { export class ILabeledIdCriterionOption extends CriterionOption {
public modifier = CriterionModifier.IncludesAll; constructor(
public modifierOptions = [ messageID: string,
Criterion.getModifierOption(CriterionModifier.IncludesAll), value: CriterionType,
Criterion.getModifierOption(CriterionModifier.Includes), parameterName: string,
Criterion.getModifierOption(CriterionModifier.Excludes), includeAll: boolean
]; ) {
const modifierOptions = [
CriterionModifier.Includes,
CriterionModifier.Excludes,
];
public options: IOptionType[] = []; let defaultModifier = CriterionModifier.Includes;
public value: ILabeledId[] = []; if (includeAll) {
modifierOptions.unshift(CriterionModifier.IncludesAll);
defaultModifier = CriterionModifier.IncludesAll;
}
super({
messageID,
type: value,
parameterName,
modifierOptions,
defaultModifier,
});
}
}
export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
public getLabelValue(): string { public getLabelValue(): string {
return this.value.map((v) => v.label).join(", "); return this.value.map((v) => v.label).join(", ");
} }
@@ -303,33 +341,12 @@ export abstract class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
}); });
} }
constructor(type: CriterionOption, includeAll: boolean) { constructor(type: CriterionOption) {
super(type); super(type, []);
if (!includeAll) {
this.modifier = CriterionModifier.Includes;
this.modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes),
];
}
} }
} }
export abstract class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> { export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
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,
};
public encodeValue() { public encodeValue() {
return { return {
items: this.value.items.map((o) => { items: this.value.items.map((o) => {
@@ -357,52 +374,41 @@ export abstract class IHierarchicalLabeledIdCriterion extends Criterion<IHierarc
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`; return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
} }
public toJSON() { constructor(type: CriterionOption) {
const encodedCriterion = { const value: IHierarchicalLabelValue = {
type: this.criterionOption.value, items: [],
value: this.encodeValue(), depth: 0,
modifier: this.modifier,
}; };
return JSON.stringify(encodedCriterion);
}
constructor(type: CriterionOption, includeAll: boolean) { super(type, value);
super(type);
if (!includeAll) {
this.modifier = CriterionModifier.Includes;
this.modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes),
];
}
} }
} }
export class MandatoryNumberCriterion extends NumberCriterion { export class MandatoryNumberCriterionOption extends CriterionOption {
public modifierOptions = [ constructor(messageID: string, value: CriterionType, parameterName?: string) {
Criterion.getModifierOption(CriterionModifier.Equals), super({
Criterion.getModifierOption(CriterionModifier.NotEquals), messageID,
Criterion.getModifierOption(CriterionModifier.GreaterThan), type: value,
Criterion.getModifierOption(CriterionModifier.LessThan), 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<number> { export class DurationCriterion extends Criterion<number> {
public modifier = CriterionModifier.Equals; constructor(type: CriterionOption) {
public modifierOptions = [ super(type, 0);
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;
} }
public getLabelValue() { public getLabelValue() {

View File

@@ -3,25 +3,27 @@ import {
StringCriterion, StringCriterion,
NumberCriterion, NumberCriterion,
DurationCriterion, DurationCriterion,
MandatoryStringCriterion, NumberCriterionOption,
MandatoryNumberCriterion, MandatoryStringCriterionOption,
CriterionOption, MandatoryNumberCriterionOption,
StringCriterionOption,
ILabeledIdCriterion,
} from "./criterion"; } from "./criterion";
import { OrganizedCriterion } from "./organized"; import { OrganizedCriterion } from "./organized";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import { import {
PerformerIsMissingCriterion, PerformerIsMissingCriterionOption,
SceneIsMissingCriterion, ImageIsMissingCriterionOption,
GalleryIsMissingCriterion, TagIsMissingCriterionOption,
TagIsMissingCriterion, SceneIsMissingCriterionOption,
StudioIsMissingCriterion, IsMissingCriterion,
MovieIsMissingCriterion, GalleryIsMissingCriterionOption,
ImageIsMissingCriterion, StudioIsMissingCriterionOption,
MovieIsMissingCriterionOption,
} from "./is-missing"; } from "./is-missing";
import { NoneCriterion } from "./none"; import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers"; import { PerformersCriterion } from "./performers";
import { RatingCriterion } from "./rating";
import { AverageResolutionCriterion, ResolutionCriterion } from "./resolution"; import { AverageResolutionCriterion, ResolutionCriterion } from "./resolution";
import { StudiosCriterion, ParentStudiosCriterion } from "./studios"; import { StudiosCriterion, ParentStudiosCriterion } from "./studios";
import { import {
@@ -31,19 +33,22 @@ import {
TagsCriterionOption, TagsCriterionOption,
} from "./tags"; } from "./tags";
import { GenderCriterion } from "./gender"; import { GenderCriterion } from "./gender";
import { MoviesCriterion } from "./movies"; import { MoviesCriterionOption } from "./movies";
import { GalleriesCriterion } from "./galleries"; import { GalleriesCriterion } from "./galleries";
import { CriterionType } from "../types"; import { CriterionType } from "../types";
import { InteractiveCriterion } from "./interactive"; import { InteractiveCriterion } from "./interactive";
import { RatingCriterionOption } from "./rating";
export function makeCriteria(type: CriterionType = "none") { export function makeCriteria(type: CriterionType = "none") {
switch (type) { switch (type) {
case "none": case "none":
return new NoneCriterion(); return new NoneCriterion();
case "path": case "path":
return new MandatoryStringCriterion(new CriterionOption(type, type)); return new StringCriterion(
new MandatoryStringCriterionOption(type, type)
);
case "rating": case "rating":
return new RatingCriterion(); return new NumberCriterion(RatingCriterionOption);
case "organized": case "organized":
return new OrganizedCriterion(); return new OrganizedCriterion();
case "o_counter": case "o_counter":
@@ -53,31 +58,33 @@ export function makeCriteria(type: CriterionType = "none") {
case "gallery_count": case "gallery_count":
case "performer_count": case "performer_count":
case "tag_count": case "tag_count":
return new MandatoryNumberCriterion(new CriterionOption(type, type)); return new NumberCriterion(
new MandatoryNumberCriterionOption(type, type)
);
case "resolution": case "resolution":
return new ResolutionCriterion(); return new ResolutionCriterion();
case "average_resolution": case "average_resolution":
return new AverageResolutionCriterion(); return new AverageResolutionCriterion();
case "duration": case "duration":
return new DurationCriterion(new CriterionOption(type, type)); return new DurationCriterion(new NumberCriterionOption(type, type));
case "favorite": case "favorite":
return new FavoriteCriterion(); return new FavoriteCriterion();
case "hasMarkers": case "hasMarkers":
return new HasMarkersCriterion(); return new HasMarkersCriterion();
case "sceneIsMissing": case "sceneIsMissing":
return new SceneIsMissingCriterion(); return new IsMissingCriterion(SceneIsMissingCriterionOption);
case "imageIsMissing": case "imageIsMissing":
return new ImageIsMissingCriterion(); return new IsMissingCriterion(ImageIsMissingCriterionOption);
case "performerIsMissing": case "performerIsMissing":
return new PerformerIsMissingCriterion(); return new IsMissingCriterion(PerformerIsMissingCriterionOption);
case "galleryIsMissing": case "galleryIsMissing":
return new GalleryIsMissingCriterion(); return new IsMissingCriterion(GalleryIsMissingCriterionOption);
case "tagIsMissing": case "tagIsMissing":
return new TagIsMissingCriterion(); return new IsMissingCriterion(TagIsMissingCriterionOption);
case "studioIsMissing": case "studioIsMissing":
return new StudioIsMissingCriterion(); return new IsMissingCriterion(StudioIsMissingCriterionOption);
case "movieIsMissing": case "movieIsMissing":
return new MovieIsMissingCriterion(); return new IsMissingCriterion(MovieIsMissingCriterionOption);
case "tags": case "tags":
return new TagsCriterion(TagsCriterionOption); return new TagsCriterion(TagsCriterionOption);
case "sceneTags": case "sceneTags":
@@ -91,15 +98,17 @@ export function makeCriteria(type: CriterionType = "none") {
case "parent_studios": case "parent_studios":
return new ParentStudiosCriterion(); return new ParentStudiosCriterion();
case "movies": case "movies":
return new MoviesCriterion(); return new ILabeledIdCriterion(MoviesCriterionOption);
case "galleries": case "galleries":
return new GalleriesCriterion(); return new GalleriesCriterion();
case "birth_year": case "birth_year":
case "death_year": case "death_year":
case "weight": case "weight":
return new NumberCriterion(new CriterionOption(type, type)); return new NumberCriterion(new NumberCriterionOption(type, type));
case "age": case "age":
return new MandatoryNumberCriterion(new CriterionOption(type, type)); return new NumberCriterion(
new MandatoryNumberCriterionOption(type, type)
);
case "gender": case "gender":
return new GenderCriterion(); return new GenderCriterion();
case "ethnicity": case "ethnicity":
@@ -115,7 +124,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "aliases": case "aliases":
case "url": case "url":
case "stash_id": case "stash_id":
return new StringCriterion(new CriterionOption(type, type)); return new StringCriterion(new StringCriterionOption(type, type));
case "interactive": case "interactive":
return new InteractiveCriterion(); return new InteractiveCriterion();
} }

View File

@@ -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", "favourite",
"favorite", "favorite",
"filter_favorites" "filter_favorites"

View File

@@ -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 { export class GalleriesCriterion extends ILabeledIdCriterion {
constructor() { constructor() {
super(galleriesCriterionOption, true); super(galleriesCriterionOption);
} }
} }

View File

@@ -1,18 +1,16 @@
import { import { GenderCriterionInput } from "src/core/generated-graphql";
CriterionModifier, import { genderStrings, stringToGender } from "src/utils/gender";
GenderCriterionInput,
} from "src/core/generated-graphql";
import { getGenderStrings, stringToGender } from "src/core/StashService";
import { CriterionOption, StringCriterion } from "./criterion"; 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 { export class GenderCriterion extends StringCriterion {
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
constructor() { constructor() {
super(GenderCriterionOption, getGenderStrings()); super(GenderCriterionOption);
} }
protected toCriterionInput(): GenderCriterionInput { protected toCriterionInput(): GenderCriterionInput {

View File

@@ -1,14 +1,15 @@
import { CriterionOption, StringCriterion } from "./criterion"; import { CriterionOption, StringCriterion } from "./criterion";
export const HasMarkersCriterionOption = new CriterionOption( export const HasMarkersCriterionOption = new CriterionOption({
"hasMarkers", messageID: "hasMarkers",
"hasMarkers", type: "hasMarkers",
"has_markers" parameterName: "has_markers",
); options: [true.toString(), false.toString()],
});
export class HasMarkersCriterion extends StringCriterion { export class HasMarkersCriterion extends StringCriterion {
constructor() { constructor() {
super(HasMarkersCriterionOption, [true.toString(), false.toString()]); super(HasMarkersCriterionOption);
} }
protected toCriterionInput(): string { protected toCriterionInput(): string {

View File

@@ -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",
"interactive" "interactive"
); );

View File

@@ -1,7 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql"; 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 modifierOptions = [];
public modifier = CriterionModifier.Equals; 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", "isMissing",
"sceneIsMissing", "sceneIsMissing",
"is_missing" "is_missing",
[
"title",
"details",
"url",
"date",
"galleries",
"studio",
"movie",
"performers",
"tags",
"stash_id",
]
); );
export class SceneIsMissingCriterion extends IsMissingCriterion { export const ImageIsMissingCriterionOption = new IsMissingCriterionOptionClass(
constructor() {
super(SceneIsMissingCriterionOption, [
"title",
"details",
"url",
"date",
"galleries",
"studio",
"movie",
"performers",
"tags",
"stash_id",
]);
}
}
export const ImageIsMissingCriterionOption = new CriterionOption(
"isMissing", "isMissing",
"imageIsMissing", "imageIsMissing",
"is_missing" "is_missing",
["title", "galleries", "studio", "performers", "tags"]
); );
export class ImageIsMissingCriterion extends IsMissingCriterion { export const PerformerIsMissingCriterionOption = new IsMissingCriterionOptionClass(
constructor() {
super(ImageIsMissingCriterionOption, [
"title",
"galleries",
"studio",
"performers",
"tags",
]);
}
}
export const PerformerIsMissingCriterionOption = new CriterionOption(
"isMissing", "isMissing",
"performerIsMissing", "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 { export const GalleryIsMissingCriterionOption = new IsMissingCriterionOptionClass(
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(
"isMissing", "isMissing",
"galleryIsMissing", "galleryIsMissing",
"is_missing" "is_missing",
["title", "details", "url", "date", "studio", "performers", "tags", "scenes"]
); );
export class GalleryIsMissingCriterion extends IsMissingCriterion { export const TagIsMissingCriterionOption = new IsMissingCriterionOptionClass(
constructor() {
super(GalleryIsMissingCriterionOption, [
"title",
"details",
"url",
"date",
"studio",
"performers",
"tags",
"scenes",
]);
}
}
export const TagIsMissingCriterionOption = new CriterionOption(
"isMissing", "isMissing",
"tagIsMissing", "tagIsMissing",
"is_missing" "is_missing",
["image"]
); );
export class TagIsMissingCriterion extends IsMissingCriterion { export const StudioIsMissingCriterionOption = new IsMissingCriterionOptionClass(
constructor() {
super(TagIsMissingCriterionOption, ["image"]);
}
}
export const StudioIsMissingCriterionOption = new CriterionOption(
"isMissing", "isMissing",
"studioIsMissing", "studioIsMissing",
"is_missing" "is_missing",
["image", "stash_id", "details"]
); );
export class StudioIsMissingCriterion extends IsMissingCriterion { export const MovieIsMissingCriterionOption = new IsMissingCriterionOptionClass(
constructor() {
super(StudioIsMissingCriterionOption, ["image", "stash_id", "details"]);
}
}
export const MovieIsMissingCriterionOption = new CriterionOption(
"isMissing", "isMissing",
"movieIsMissing", "movieIsMissing",
"is_missing" "is_missing",
["front_image", "back_image", "scenes"]
); );
export class MovieIsMissingCriterion extends IsMissingCriterion {
constructor() {
super(MovieIsMissingCriterionOption, [
"front_image",
"back_image",
"scenes",
]);
}
}

View File

@@ -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 { export class MoviesCriterion extends ILabeledIdCriterion {
constructor() { constructor() {
super(MoviesCriterionOption, false); super(MoviesCriterionOption);
} }
} }

View File

@@ -1,15 +1,13 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { Criterion, StringCriterionOption } from "./criterion";
import { Criterion, CriterionOption } from "./criterion";
export const NoneCriterionOption = new CriterionOption("none", "none"); export const NoneCriterionOption = new StringCriterionOption(
"none",
"none",
"none"
);
export class NoneCriterion extends Criterion<string> { export class NoneCriterion extends Criterion<string> {
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
public options: undefined;
public value: string = "none";
constructor() { constructor() {
super(NoneCriterionOption); super(NoneCriterionOption, "none");
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this

View File

@@ -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",
"organized" "organized"
); );

View File

@@ -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",
"performers",
true
); );
export class PerformersCriterion extends ILabeledIdCriterion { export class PerformersCriterion extends ILabeledIdCriterion {
constructor() { constructor() {
super(PerformersCriterionOption, true); super(PerformersCriterionOption);
} }
} }

View File

@@ -1,20 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { NumberCriterionOption } from "./criterion";
import { Criterion, CriterionOption, NumberCriterion } from "./criterion";
export const RatingCriterionOption = new CriterionOption("rating", "rating"); export const RatingCriterionOption = new NumberCriterionOption(
"rating",
export class RatingCriterion extends NumberCriterion { "rating",
public modifier = CriterionModifier.Equals; "rating",
public modifierOptions = [ [1, 2, 3, 4, 5]
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]);
}
}

View File

@@ -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"; import { CriterionOption, StringCriterion } from "./criterion";
abstract class AbstractResolutionCriterion extends StringCriterion { 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 { protected toCriterionInput(): ResolutionEnum | undefined {
switch (this.value) { switch (this.value) {
case "144p": case "144p":
@@ -55,21 +36,40 @@ abstract class AbstractResolutionCriterion extends StringCriterion {
} }
} }
export const ResolutionCriterionOption = new CriterionOption( class ResolutionCriterionOptionType extends CriterionOption {
"resolution", 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" "resolution"
); );
export class ResolutionCriterion extends AbstractResolutionCriterion { export class ResolutionCriterion extends AbstractResolutionCriterion {
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
constructor() { constructor() {
super(ResolutionCriterionOption); super(ResolutionCriterionOption);
} }
} }
export const AverageResolutionCriterionOption = new CriterionOption( export const AverageResolutionCriterionOption = new ResolutionCriterionOptionType(
"average_resolution",
"average_resolution" "average_resolution"
); );

View File

@@ -1,23 +1,30 @@
import { import {
CriterionOption,
IHierarchicalLabeledIdCriterion, IHierarchicalLabeledIdCriterion,
ILabeledIdCriterion, ILabeledIdCriterion,
ILabeledIdCriterionOption,
} from "./criterion"; } from "./criterion";
export const StudiosCriterionOption = new CriterionOption("studios", "studios"); export const StudiosCriterionOption = new ILabeledIdCriterionOption(
"studios",
"studios",
"studios",
false
);
export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { export class StudiosCriterion extends IHierarchicalLabeledIdCriterion {
constructor() { constructor() {
super(StudiosCriterionOption, false); super(StudiosCriterionOption);
} }
} }
export const ParentStudiosCriterionOption = new CriterionOption( export const ParentStudiosCriterionOption = new ILabeledIdCriterionOption(
"parent_studios", "parent_studios",
"parent_studios", "parent_studios",
"parents" "parents",
false
); );
export class ParentStudiosCriterion extends ILabeledIdCriterion { export class ParentStudiosCriterion extends ILabeledIdCriterion {
constructor() { constructor() {
super(ParentStudiosCriterionOption, false); super(ParentStudiosCriterionOption);
} }
} }

View File

@@ -1,19 +1,22 @@
import { CriterionOption, ILabeledIdCriterion } from "./criterion"; import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion";
export class TagsCriterion extends ILabeledIdCriterion { export class TagsCriterion extends ILabeledIdCriterion {}
constructor(type: CriterionOption) {
super(type, true);
}
}
export const TagsCriterionOption = new CriterionOption("tags", "tags"); export const TagsCriterionOption = new ILabeledIdCriterionOption(
export const SceneTagsCriterionOption = new CriterionOption( "tags",
"tags",
"tags",
true
);
export const SceneTagsCriterionOption = new ILabeledIdCriterionOption(
"sceneTags", "sceneTags",
"sceneTags", "sceneTags",
"scene_tags" "scene_tags",
true
); );
export const PerformerTagsCriterionOption = new CriterionOption( export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption(
"performerTags", "performerTags",
"performerTags", "performerTags",
"performer_tags" "performer_tags",
true
); );

View File

@@ -1,3 +1,4 @@
import { FilterMode } from "src/core/generated-graphql";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { GalleryListFilterOptions } from "./galleries"; import { GalleryListFilterOptions } from "./galleries";
import { ImageListFilterOptions } from "./images"; import { ImageListFilterOptions } from "./images";
@@ -7,7 +8,6 @@ import { SceneMarkerListFilterOptions } from "./scene-markers";
import { SceneListFilterOptions } from "./scenes"; import { SceneListFilterOptions } from "./scenes";
import { StudioListFilterOptions } from "./studios"; import { StudioListFilterOptions } from "./studios";
import { TagListFilterOptions } from "./tags"; import { TagListFilterOptions } from "./tags";
import { FilterMode } from "./types";
export function getFilterOptions(mode: FilterMode): ListFilterOptions { export function getFilterOptions(mode: FilterMode): ListFilterOptions {
switch (mode) { switch (mode) {

View File

@@ -1,5 +1,9 @@
import queryString, { ParsedQuery } from "query-string"; 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 { Criterion, CriterionValue } from "./criteria/criterion";
import { makeCriteria } from "./criteria/factory"; import { makeCriteria } from "./criteria/factory";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
@@ -23,6 +27,7 @@ const DEFAULT_PARAMS = {
// TODO: handle customCriteria // TODO: handle customCriteria
export class ListFilterModel { export class ListFilterModel {
public mode: FilterMode;
public searchTerm?: string; public searchTerm?: string;
public currentPage = DEFAULT_PARAMS.currentPage; public currentPage = DEFAULT_PARAMS.currentPage;
public itemsPerPage = DEFAULT_PARAMS.itemsPerPage; public itemsPerPage = DEFAULT_PARAMS.itemsPerPage;
@@ -33,16 +38,22 @@ export class ListFilterModel {
public randomSeed = -1; public randomSeed = -1;
public constructor( public constructor(
mode: FilterMode,
rawParms?: ParsedQuery<string>, rawParms?: ParsedQuery<string>,
defaultSort?: string, defaultSort?: string,
defaultDisplayMode?: DisplayMode defaultDisplayMode?: DisplayMode
) { ) {
this.mode = mode;
const params = rawParms as IQueryParameters; const params = rawParms as IQueryParameters;
this.sortBy = defaultSort; this.sortBy = defaultSort;
if (defaultDisplayMode !== undefined) this.displayMode = defaultDisplayMode; if (defaultDisplayMode !== undefined) this.displayMode = defaultDisplayMode;
if (params) this.configureFromQueryParameters(params); if (params) this.configureFromQueryParameters(params);
} }
public clone() {
return Object.assign(new ListFilterModel(this.mode), this);
}
public configureFromQueryParameters(params: IQueryParameters) { public configureFromQueryParameters(params: IQueryParameters) {
if (params.sortby !== undefined) { if (params.sortby !== undefined) {
this.sortBy = params.sortby; this.sortBy = params.sortby;
@@ -64,7 +75,7 @@ export class ListFilterModel {
params.sortdir === "desc" params.sortdir === "desc"
? SortDirectionEnum.Desc ? SortDirectionEnum.Desc
: SortDirectionEnum.Asc; : SortDirectionEnum.Asc;
if (params.disp) { if (params.disp !== undefined) {
this.displayMode = Number.parseInt(params.disp, 10); this.displayMode = Number.parseInt(params.disp, 10);
} }
if (params.q) { if (params.q) {
@@ -153,6 +164,24 @@ export class ListFilterModel {
return result; 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 { public makeQueryParameters(): string {
return queryString.stringify(this.getQueryParameters(), { encode: false }); return queryString.stringify(this.getQueryParameters(), { encode: false });
} }

View File

@@ -1,6 +1,5 @@
import { createCriterionOption } from "./criteria/criterion"; import { createStringCriterionOption } from "./criteria/criterion";
import { GalleryIsMissingCriterionOption } from "./criteria/is-missing"; import { GalleryIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { OrganizedCriterionOption } from "./criteria/organized"; import { OrganizedCriterionOption } from "./criteria/organized";
import { PerformersCriterionOption } from "./criteria/performers"; import { PerformersCriterionOption } from "./criteria/performers";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
@@ -39,20 +38,19 @@ const displayModeOptions = [
]; ];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption, createStringCriterionOption("path"),
createCriterionOption("path"),
RatingCriterionOption, RatingCriterionOption,
OrganizedCriterionOption, OrganizedCriterionOption,
AverageResolutionCriterionOption, AverageResolutionCriterionOption,
GalleryIsMissingCriterionOption, GalleryIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,
createCriterionOption("tag_count"), createStringCriterionOption("tag_count"),
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,
createCriterionOption("performer_count"), createStringCriterionOption("performer_count"),
createCriterionOption("image_count"), createStringCriterionOption("image_count"),
StudiosCriterionOption, StudiosCriterionOption,
createCriterionOption("url"), createStringCriterionOption("url"),
]; ];
export const GalleryListFilterOptions = new ListFilterOptions( export const GalleryListFilterOptions = new ListFilterOptions(

View File

@@ -1,6 +1,8 @@
import { createCriterionOption } from "./criteria/criterion"; import {
createMandatoryNumberCriterionOption,
createStringCriterionOption,
} from "./criteria/criterion";
import { ImageIsMissingCriterionOption } from "./criteria/is-missing"; import { ImageIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { OrganizedCriterionOption } from "./criteria/organized"; import { OrganizedCriterionOption } from "./criteria/organized";
import { PerformersCriterionOption } from "./criteria/performers"; import { PerformersCriterionOption } from "./criteria/performers";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
@@ -29,18 +31,17 @@ const sortByOptions = [
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption, createStringCriterionOption("path"),
createCriterionOption("path"),
RatingCriterionOption, RatingCriterionOption,
OrganizedCriterionOption, OrganizedCriterionOption,
createCriterionOption("o_counter"), createMandatoryNumberCriterionOption("o_counter"),
ResolutionCriterionOption, ResolutionCriterionOption,
ImageIsMissingCriterionOption, ImageIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,
createCriterionOption("tag_count"), createMandatoryNumberCriterionOption("tag_count"),
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,
createCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
StudiosCriterionOption, StudiosCriterionOption,
]; ];
export const ImageListFilterOptions = new ListFilterOptions( export const ImageListFilterOptions = new ListFilterOptions(

View File

@@ -1,6 +1,5 @@
import { createCriterionOption } from "./criteria/criterion"; import { createStringCriterionOption } from "./criteria/criterion";
import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
@@ -17,10 +16,9 @@ const sortByOptions = ["name", "random"]
]); ]);
const displayModeOptions = [DisplayMode.Grid]; const displayModeOptions = [DisplayMode.Grid];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
MovieIsMissingCriterionOption, MovieIsMissingCriterionOption,
createCriterionOption("url"), createStringCriterionOption("url"),
]; ];
export const MovieListFilterOptions = new ListFilterOptions( export const MovieListFilterOptions = new ListFilterOptions(

View File

@@ -1,8 +1,11 @@
import { createCriterionOption } from "./criteria/criterion"; import {
createNumberCriterionOption,
createMandatoryNumberCriterionOption,
createStringCriterionOption,
} from "./criteria/criterion";
import { FavoriteCriterionOption } from "./criteria/favorite"; import { FavoriteCriterionOption } from "./criteria/favorite";
import { GenderCriterionOption } from "./criteria/gender"; import { GenderCriterionOption } from "./criteria/gender";
import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
import { TagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags";
@@ -55,19 +58,19 @@ const stringCriteria: CriterionType[] = [
]; ];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption,
FavoriteCriterionOption, FavoriteCriterionOption,
GenderCriterionOption, GenderCriterionOption,
PerformerIsMissingCriterionOption, PerformerIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,
RatingCriterionOption, RatingCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
createCriterionOption("url"), createStringCriterionOption("url"),
createCriterionOption("tag_count"), createMandatoryNumberCriterionOption("tag_count"),
createCriterionOption("scene_count"), createMandatoryNumberCriterionOption("scene_count"),
createCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),
...numberCriteria.concat(stringCriteria).map((c) => createCriterionOption(c)), ...numberCriteria.map((c) => createNumberCriterionOption(c)),
...stringCriteria.map((c) => createStringCriterionOption(c)),
]; ];
export const PerformerListFilterOptions = new ListFilterOptions( export const PerformerListFilterOptions = new ListFilterOptions(
defaultSortBy, defaultSortBy,

View File

@@ -1,4 +1,3 @@
import { NoneCriterionOption } from "./criteria/none";
import { PerformersCriterionOption } from "./criteria/performers"; import { PerformersCriterionOption } from "./criteria/performers";
import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags"; import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
@@ -14,7 +13,6 @@ const sortByOptions = [
].map(ListFilterOptions.createSortBy); ].map(ListFilterOptions.createSortBy);
const displayModeOptions = [DisplayMode.Wall]; const displayModeOptions = [DisplayMode.Wall];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption,
TagsCriterionOption, TagsCriterionOption,
SceneTagsCriterionOption, SceneTagsCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,

View File

@@ -1,8 +1,10 @@
import { createCriterionOption } from "./criteria/criterion"; import {
createMandatoryNumberCriterionOption,
createStringCriterionOption,
} from "./criteria/criterion";
import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { HasMarkersCriterionOption } from "./criteria/has-markers";
import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing";
import { MoviesCriterionOption } from "./criteria/movies"; import { MoviesCriterionOption } from "./criteria/movies";
import { NoneCriterionOption } from "./criteria/none";
import { OrganizedCriterionOption } from "./criteria/organized"; import { OrganizedCriterionOption } from "./criteria/organized";
import { PerformersCriterionOption } from "./criteria/performers"; import { PerformersCriterionOption } from "./criteria/performers";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
@@ -44,24 +46,23 @@ const displayModeOptions = [
]; ];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption, createStringCriterionOption("path"),
createCriterionOption("path"),
RatingCriterionOption, RatingCriterionOption,
OrganizedCriterionOption, OrganizedCriterionOption,
createCriterionOption("o_counter"), createMandatoryNumberCriterionOption("o_counter"),
ResolutionCriterionOption, ResolutionCriterionOption,
createCriterionOption("duration"), createMandatoryNumberCriterionOption("duration"),
HasMarkersCriterionOption, HasMarkersCriterionOption,
SceneIsMissingCriterionOption, SceneIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,
createCriterionOption("tag_count"), createMandatoryNumberCriterionOption("tag_count"),
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,
createCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
StudiosCriterionOption, StudiosCriterionOption,
MoviesCriterionOption, MoviesCriterionOption,
createCriterionOption("url"), createStringCriterionOption("url"),
createCriterionOption("stash_id"), createStringCriterionOption("stash_id"),
InteractiveCriterionOption, InteractiveCriterionOption,
]; ];

View File

@@ -1,6 +1,8 @@
import { createCriterionOption } from "./criteria/criterion"; import {
createMandatoryNumberCriterionOption,
createStringCriterionOption,
} from "./criteria/criterion";
import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
import { ParentStudiosCriterionOption } from "./criteria/studios"; import { ParentStudiosCriterionOption } from "./criteria/studios";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
@@ -26,15 +28,14 @@ const sortByOptions = ["name", "random", "rating"]
const displayModeOptions = [DisplayMode.Grid]; const displayModeOptions = [DisplayMode.Grid];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption,
ParentStudiosCriterionOption, ParentStudiosCriterionOption,
StudioIsMissingCriterionOption, StudioIsMissingCriterionOption,
RatingCriterionOption, RatingCriterionOption,
createCriterionOption("scene_count"), createMandatoryNumberCriterionOption("scene_count"),
createCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),
createCriterionOption("url"), createStringCriterionOption("url"),
createCriterionOption("stash_id"), createStringCriterionOption("stash_id"),
]; ];
export const StudioListFilterOptions = new ListFilterOptions( export const StudioListFilterOptions = new ListFilterOptions(

View File

@@ -1,6 +1,5 @@
import { createCriterionOption } from "./criteria/criterion"; import { createMandatoryNumberCriterionOption } from "./criteria/criterion";
import { TagIsMissingCriterionOption } from "./criteria/is-missing"; import { TagIsMissingCriterionOption } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
@@ -34,12 +33,11 @@ const sortByOptions = [
const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
const criterionOptions = [ const criterionOptions = [
NoneCriterionOption,
TagIsMissingCriterionOption, TagIsMissingCriterionOption,
createCriterionOption("scene_count"), createMandatoryNumberCriterionOption("scene_count"),
createCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),
createCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
// marker count has been disabled for now due to performance issues // marker count has been disabled for now due to performance issues
// ListFilterModel.createCriterionOption("marker_count"), // ListFilterModel.createCriterionOption("marker_count"),
]; ];

View File

@@ -8,17 +8,6 @@ export enum DisplayMode {
Tagger, Tagger,
} }
export enum FilterMode {
Scenes,
Performers,
Studios,
Galleries,
SceneMarkers,
Movies,
Tags,
Images,
}
export interface ILabeledId { export interface ILabeledId {
id: string; id: string;
label: string; label: string;

View File

@@ -1,5 +1,6 @@
import queryString from "query-string"; import queryString from "query-string";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { FilterMode } from "src/core/generated-graphql";
import { ListFilterModel } from "./list-filter/filter"; import { ListFilterModel } from "./list-filter/filter";
import { SceneListFilterOptions } from "./list-filter/scenes"; import { SceneListFilterOptions } from "./list-filter/scenes";
@@ -27,7 +28,7 @@ export class SceneQueue {
public static fromListFilterModel(filter: ListFilterModel) { public static fromListFilterModel(filter: ListFilterModel) {
const ret = new SceneQueue(); const ret = new SceneQueue();
const filterCopy = Object.assign(new ListFilterModel(), filter); const filterCopy = filter.clone();
filterCopy.itemsPerPage = 40; filterCopy.itemsPerPage = 40;
ret.originalQueryPage = filter.currentPage; ret.originalQueryPage = filter.currentPage;
@@ -95,6 +96,7 @@ export class SceneQueue {
if (parsed.qfp) { if (parsed.qfp) {
const query = new ListFilterModel( const query = new ListFilterModel(
FilterMode.Scenes,
translated as queryString.ParsedQuery, translated as queryString.ParsedQuery,
SceneListFilterOptions.defaultSortBy SceneListFilterOptions.defaultSortBy
); );

View File

@@ -0,0 +1,49 @@
import * as GQL from "../core/generated-graphql";
export const stringGenderMap = new Map<string, GQL.GenderEnum>([
["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());

View File

@@ -30,7 +30,7 @@ const makePerformerScenesUrl = (
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Scenes);
const criterion = new PerformersCriterion(); const criterion = new PerformersCriterion();
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
@@ -45,7 +45,7 @@ const makePerformerImagesUrl = (
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Images);
const criterion = new PerformersCriterion(); const criterion = new PerformersCriterion();
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
@@ -60,7 +60,7 @@ const makePerformerGalleriesUrl = (
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Galleries);
const criterion = new PerformersCriterion(); const criterion = new PerformersCriterion();
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
@@ -74,7 +74,7 @@ const makePerformersCountryUrl = (
performer: Partial<GQL.PerformerDataFragment> performer: Partial<GQL.PerformerDataFragment>
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Performers);
const criterion = new CountryCriterion(); const criterion = new CountryCriterion();
criterion.value = `${performer.country}`; criterion.value = `${performer.country}`;
filter.criteria.push(criterion); filter.criteria.push(criterion);
@@ -83,7 +83,7 @@ const makePerformersCountryUrl = (
const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Scenes);
const criterion = new StudiosCriterion(); const criterion = new StudiosCriterion();
criterion.value = { criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
@@ -95,7 +95,7 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Images);
const criterion = new StudiosCriterion(); const criterion = new StudiosCriterion();
criterion.value = { criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
@@ -107,7 +107,7 @@ const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Galleries);
const criterion = new StudiosCriterion(); const criterion = new StudiosCriterion();
criterion.value = { criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
@@ -119,7 +119,7 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Studios);
const criterion = new ParentStudiosCriterion(); const criterion = new ParentStudiosCriterion();
criterion.value = [ criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` }, { id: studio.id, label: studio.name || `Studio ${studio.id}` },
@@ -130,7 +130,7 @@ const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {
const makeMovieScenesUrl = (movie: Partial<GQL.MovieDataFragment>) => { const makeMovieScenesUrl = (movie: Partial<GQL.MovieDataFragment>) => {
if (!movie.id) return "#"; if (!movie.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Scenes);
const criterion = new MoviesCriterion(); const criterion = new MoviesCriterion();
criterion.value = [ criterion.value = [
{ id: movie.id, label: movie.name || `Movie ${movie.id}` }, { id: movie.id, label: movie.name || `Movie ${movie.id}` },
@@ -141,7 +141,7 @@ const makeMovieScenesUrl = (movie: Partial<GQL.MovieDataFragment>) => {
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Scenes);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
@@ -150,7 +150,7 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Performers);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
@@ -159,7 +159,7 @@ const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => {
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
@@ -168,7 +168,7 @@ const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Galleries);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
@@ -177,7 +177,7 @@ const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => {
const makeTagImagesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagImagesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(); const filter = new ListFilterModel(GQL.FilterMode.Images);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);