mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Filter studio hierarchy (#1397)
* Add basic support for hierarchical filters Add a new `hierarchicalMultiCriterionHandlerBuilder` filter type which can / will be used for filtering hierarchical things like the parent/child relation of the studios. On the frontend side a new IHierarchicalLabeledIdCriterion criterion type has been added to accompany this new filter type. * Refactor movieQueryBuilder to use filterBuilder Refactor the movieQueryBuilder to use the filterBuilder just as scene, image and gallery as well. * Support specifying depth for studios filter Add an optional depth field to the studios filter for scenes, images, galleries and movies. When specified that number of included (grant)children are shown as well. In other words: this adds support for showing scenes set to child studios when searching on the parent studio. Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
508f7b84f2
commit
7164bb28ac
27 changed files with 595 additions and 100 deletions
|
|
@ -122,7 +122,7 @@ input SceneFilterType {
|
|||
"""Filter to only include scenes missing this property"""
|
||||
is_missing: String
|
||||
"""Filter to only include scenes with this studio"""
|
||||
studios: MultiCriterionInput
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include scenes with this movie"""
|
||||
movies: MultiCriterionInput
|
||||
"""Filter to only include scenes with these tags"""
|
||||
|
|
@ -145,7 +145,7 @@ input SceneFilterType {
|
|||
|
||||
input MovieFilterType {
|
||||
"""Filter to only include movies with this studio"""
|
||||
studios: MultiCriterionInput
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include movies missing this property"""
|
||||
is_missing: String
|
||||
"""Filter by url"""
|
||||
|
|
@ -189,7 +189,7 @@ input GalleryFilterType {
|
|||
"""Filter by average image resolution"""
|
||||
average_resolution: ResolutionEnum
|
||||
"""Filter to only include galleries with this studio"""
|
||||
studios: MultiCriterionInput
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include galleries with these tags"""
|
||||
tags: MultiCriterionInput
|
||||
"""Filter by tag count"""
|
||||
|
|
@ -254,7 +254,7 @@ input ImageFilterType {
|
|||
"""Filter to only include images missing this property"""
|
||||
is_missing: String
|
||||
"""Filter to only include images with this studio"""
|
||||
studios: MultiCriterionInput
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include images with these tags"""
|
||||
tags: MultiCriterionInput
|
||||
"""Filter by tag count"""
|
||||
|
|
@ -311,3 +311,9 @@ input GenderCriterionInput {
|
|||
value: GenderEnum
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
input HierarchicalMultiCriterionInput {
|
||||
value: [ID!]
|
||||
modifier: CriterionModifier!
|
||||
depth: Int!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -505,9 +505,10 @@ func (me *contentDirectoryService) getStudios() []interface{} {
|
|||
|
||||
func (me *contentDirectoryService) getStudioScenes(paths []string, host string) []interface{} {
|
||||
sceneFilter := &models.SceneFilterType{
|
||||
Studios: &models.MultiCriterionInput{
|
||||
Studios: &models.HierarchicalMultiCriterionInput{
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Value: []string{paths[0]},
|
||||
Depth: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ func CountByPerformerID(r models.GalleryReader, id int) (int, error) {
|
|||
|
||||
func CountByStudioID(r models.GalleryReader, id int) (int, error) {
|
||||
filter := &models.GalleryFilterType{
|
||||
Studios: &models.MultiCriterionInput{
|
||||
Studios: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(id)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ func CountByPerformerID(r models.ImageReader, id int) (int, error) {
|
|||
|
||||
func CountByStudioID(r models.ImageReader, id int) (int, error) {
|
||||
filter := &models.ImageFilterType{
|
||||
Studios: &models.MultiCriterionInput{
|
||||
Studios: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(id)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ type filterBuilder struct {
|
|||
joins joins
|
||||
whereClauses []sqlClause
|
||||
havingClauses []sqlClause
|
||||
withClauses []sqlClause
|
||||
|
||||
err error
|
||||
}
|
||||
|
|
@ -169,6 +170,15 @@ func (f *filterBuilder) addHaving(sql string, args ...interface{}) {
|
|||
f.havingClauses = append(f.havingClauses, makeClause(sql, args...))
|
||||
}
|
||||
|
||||
// addWith adds a with clause and arguments to the filter
|
||||
func (f *filterBuilder) addWith(sql string, args ...interface{}) {
|
||||
if sql == "" {
|
||||
return
|
||||
}
|
||||
|
||||
f.withClauses = append(f.withClauses, makeClause(sql, args...))
|
||||
}
|
||||
|
||||
func (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string {
|
||||
ret := clause
|
||||
|
||||
|
|
@ -226,6 +236,21 @@ func (f *filterBuilder) generateHavingClauses() (string, []interface{}) {
|
|||
return clause, args
|
||||
}
|
||||
|
||||
func (f *filterBuilder) generateWithClauses() (string, []interface{}) {
|
||||
var clauses []string
|
||||
var args []interface{}
|
||||
for _, w := range f.withClauses {
|
||||
clauses = append(clauses, w.sql)
|
||||
args = append(args, w.args...)
|
||||
}
|
||||
|
||||
if len(clauses) > 0 {
|
||||
return strings.Join(clauses, ", "), args
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getAllJoins returns all of the joins in this filter and any sub-filter(s).
|
||||
// Redundant joins will not be duplicated in the return value.
|
||||
func (f *filterBuilder) getAllJoins() joins {
|
||||
|
|
@ -501,6 +526,58 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit
|
|||
m.addJoinTable(f)
|
||||
|
||||
stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(f)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type hierarchicalMultiCriterionHandlerBuilder struct {
|
||||
primaryTable string
|
||||
foreignTable string
|
||||
foreignFK string
|
||||
|
||||
derivedTable string
|
||||
parentFK string
|
||||
}
|
||||
|
||||
func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(f *filterBuilder) {
|
||||
if criterion != nil && len(criterion.Value) > 0 {
|
||||
var args []interface{}
|
||||
for _, value := range criterion.Value {
|
||||
args = append(args, value)
|
||||
}
|
||||
inCount := len(args)
|
||||
|
||||
f.addJoin(m.derivedTable, "", fmt.Sprintf("%s.child_id = %s.%s", m.derivedTable, m.primaryTable, m.foreignFK))
|
||||
|
||||
var depthCondition string
|
||||
if criterion.Depth != -1 {
|
||||
depthCondition = "WHERE depth < ?"
|
||||
args = append(args, criterion.Depth)
|
||||
}
|
||||
|
||||
withClause := fmt.Sprintf(
|
||||
"RECURSIVE %s AS (SELECT id as id, id as child_id, 0 as depth FROM %s WHERE id in %s UNION SELECT p.id, c.id, depth + 1 FROM %s as c INNER JOIN %s as p ON c.%s = p.child_id %s)",
|
||||
m.derivedTable,
|
||||
m.foreignTable,
|
||||
getInBinding(inCount),
|
||||
m.foreignTable,
|
||||
m.derivedTable,
|
||||
m.parentFK,
|
||||
depthCondition,
|
||||
)
|
||||
|
||||
f.addWith(withClause, args...)
|
||||
|
||||
if criterion.Modifier == models.CriterionModifierIncludes {
|
||||
f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable))
|
||||
} else if criterion.Modifier == models.CriterionModifierIncludesAll {
|
||||
f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable))
|
||||
f.addHaving(fmt.Sprintf("count(distinct %s.id) IS %d", m.derivedTable, inCount))
|
||||
} else if criterion.Modifier == models.CriterionModifierExcludes {
|
||||
f.addWhere(fmt.Sprintf("%s.id IS NULL", m.derivedTable))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,11 +382,14 @@ func galleryImageCountCriterionHandler(qb *galleryQueryBuilder, imageCount *mode
|
|||
return h.handler(imageCount)
|
||||
}
|
||||
|
||||
func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
addJoinsFunc := func(f *filterBuilder) {
|
||||
f.addJoin(studioTable, "studio", "studio.id = galleries.studio_id")
|
||||
func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: galleryTable,
|
||||
foreignTable: studioTable,
|
||||
foreignFK: studioIDColumn,
|
||||
derivedTable: "studio",
|
||||
parentFK: "parent_id",
|
||||
}
|
||||
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
|
||||
|
||||
return h.handler(studios)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -675,11 +675,12 @@ func TestGalleryQueryTags(t *testing.T) {
|
|||
func TestGalleryQueryStudio(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Gallery()
|
||||
studioCriterion := models.MultiCriterionInput{
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGallery]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
galleryFilter := models.GalleryFilterType{
|
||||
|
|
@ -693,11 +694,12 @@ func TestGalleryQueryStudio(t *testing.T) {
|
|||
// ensure id is correct
|
||||
assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID)
|
||||
|
||||
studioCriterion = models.MultiCriterionInput{
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGallery]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
q := getGalleryStringValue(galleryIdxWithStudio, titleField)
|
||||
|
|
@ -712,6 +714,64 @@ func TestGalleryQueryStudio(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGalleryQueryStudioDepth(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Gallery()
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
galleryFilter := models.GalleryFilterType{
|
||||
Studios: &studioCriterion,
|
||||
}
|
||||
|
||||
galleries := queryGallery(t, sqb, &galleryFilter, nil)
|
||||
assert.Len(t, galleries, 1)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
|
||||
galleries = queryGallery(t, sqb, &galleryFilter, nil)
|
||||
assert.Len(t, galleries, 0)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
galleries = queryGallery(t, sqb, &galleryFilter, nil)
|
||||
assert.Len(t, galleries, 1)
|
||||
|
||||
// ensure id is correct
|
||||
assert.Equal(t, galleryIDs[galleryIdxWithGrandChildStudio], galleries[0].ID)
|
||||
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
q := getGalleryStringValue(galleryIdxWithGrandChildStudio, pathField)
|
||||
findFilter := models.FindFilterType{
|
||||
Q: &q,
|
||||
}
|
||||
|
||||
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
|
||||
assert.Len(t, galleries, 0)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
|
||||
assert.Len(t, galleries, 1)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
|
||||
assert.Len(t, galleries, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestGalleryQueryPerformerTags(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Gallery()
|
||||
|
|
|
|||
|
|
@ -408,11 +408,14 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *
|
|||
return h.handler(performerCount)
|
||||
}
|
||||
|
||||
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
addJoinsFunc := func(f *filterBuilder) {
|
||||
f.addJoin(studioTable, "studio", "studio.id = images.studio_id")
|
||||
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: imageTable,
|
||||
foreignTable: studioTable,
|
||||
foreignFK: studioIDColumn,
|
||||
derivedTable: "studio",
|
||||
parentFK: "parent_id",
|
||||
}
|
||||
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
|
||||
|
||||
return h.handler(studios)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -783,11 +783,12 @@ func TestImageQueryTags(t *testing.T) {
|
|||
func TestImageQueryStudio(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Image()
|
||||
studioCriterion := models.MultiCriterionInput{
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithImage]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
imageFilter := models.ImageFilterType{
|
||||
|
|
@ -804,11 +805,12 @@ func TestImageQueryStudio(t *testing.T) {
|
|||
// ensure id is correct
|
||||
assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID)
|
||||
|
||||
studioCriterion = models.MultiCriterionInput{
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithImage]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
q := getImageStringValue(imageIdxWithStudio, titleField)
|
||||
|
|
@ -826,6 +828,64 @@ func TestImageQueryStudio(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestImageQueryStudioDepth(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Image()
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
imageFilter := models.ImageFilterType{
|
||||
Studios: &studioCriterion,
|
||||
}
|
||||
|
||||
images := queryImages(t, sqb, &imageFilter, nil)
|
||||
assert.Len(t, images, 1)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
|
||||
images = queryImages(t, sqb, &imageFilter, nil)
|
||||
assert.Len(t, images, 0)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
images = queryImages(t, sqb, &imageFilter, nil)
|
||||
assert.Len(t, images, 1)
|
||||
|
||||
// ensure id is correct
|
||||
assert.Equal(t, imageIDs[imageIdxWithGrandChildStudio], images[0].ID)
|
||||
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
q := getImageStringValue(imageIdxWithGrandChildStudio, titleField)
|
||||
findFilter := models.FindFilterType{
|
||||
Q: &q,
|
||||
}
|
||||
|
||||
images = queryImages(t, sqb, &imageFilter, &findFilter)
|
||||
assert.Len(t, images, 0)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
images = queryImages(t, sqb, &imageFilter, &findFilter)
|
||||
assert.Len(t, images, 1)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
images = queryImages(t, sqb, &imageFilter, &findFilter)
|
||||
assert.Len(t, images, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image {
|
||||
images, _, err := sqb.Query(imageFilter, findFilter)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
)
|
||||
|
||||
const movieTable = "movies"
|
||||
const movieIDColumn = "movie_id"
|
||||
|
||||
type movieQueryBuilder struct {
|
||||
repository
|
||||
|
|
@ -114,6 +115,16 @@ func (qb *movieQueryBuilder) All() ([]*models.Movie, error) {
|
|||
return qb.queryMovies(selectAll("movies")+qb.getMovieSort(nil), nil)
|
||||
}
|
||||
|
||||
func (qb *movieQueryBuilder) makeFilter(movieFilter *models.MovieFilterType) *filterBuilder {
|
||||
query := &filterBuilder{}
|
||||
|
||||
query.handleCriterionFunc(movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
|
||||
query.handleCriterionFunc(stringCriterionHandler(movieFilter.URL, "movies.url"))
|
||||
query.handleCriterionFunc(movieStudioCriterionHandler(qb, movieFilter.Studios))
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) {
|
||||
if findFilter == nil {
|
||||
findFilter = &models.FindFilterType{}
|
||||
|
|
@ -125,11 +136,6 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
|
|||
query := qb.newQuery()
|
||||
|
||||
query.body = selectDistinctIDs("movies")
|
||||
query.body += `
|
||||
left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id
|
||||
left join scenes on scenes_join.scene_id = scenes.id
|
||||
left join studios as studio on studio.id = movies.studio_id
|
||||
`
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"movies.name"}
|
||||
|
|
@ -138,36 +144,9 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
|
|||
query.addArg(thisArgs...)
|
||||
}
|
||||
|
||||
if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
|
||||
for _, studioID := range studiosFilter.Value {
|
||||
query.addArg(studioID)
|
||||
}
|
||||
filter := qb.makeFilter(movieFilter)
|
||||
|
||||
whereClause, havingClause := getMultiCriterionClause("movies", "studio", "", "", "studio_id", studiosFilter)
|
||||
query.addWhere(whereClause)
|
||||
query.addHaving(havingClause)
|
||||
}
|
||||
|
||||
if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
case "front_image":
|
||||
query.body += `left join movies_images on movies_images.movie_id = movies.id
|
||||
`
|
||||
query.addWhere("movies_images.front_image IS NULL")
|
||||
case "back_image":
|
||||
query.body += `left join movies_images on movies_images.movie_id = movies.id
|
||||
`
|
||||
query.addWhere("movies_images.back_image IS NULL")
|
||||
case "scenes":
|
||||
query.body += `left join movies_scenes on movies_scenes.movie_id = movies.id
|
||||
`
|
||||
query.addWhere("movies_scenes.scene_id IS NULL")
|
||||
default:
|
||||
query.addWhere("movies." + *isMissingFilter + " IS NULL")
|
||||
}
|
||||
}
|
||||
|
||||
query.handleStringCriterionInput(movieFilter.URL, "movies.url")
|
||||
query.addFilter(filter)
|
||||
|
||||
query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter)
|
||||
idsResult, countResult, err := query.executeFind()
|
||||
|
|
@ -188,6 +167,38 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt
|
|||
return movies, countResult, nil
|
||||
}
|
||||
|
||||
func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) criterionHandlerFunc {
|
||||
return func(f *filterBuilder) {
|
||||
if isMissing != nil && *isMissing != "" {
|
||||
switch *isMissing {
|
||||
case "front_image":
|
||||
f.addJoin("movies_images", "", "movies_images.movie_id = movies.id")
|
||||
f.addWhere("movies_images.front_image IS NULL")
|
||||
case "back_image":
|
||||
f.addJoin("movies_images", "", "movies_images.movie_id = movies.id")
|
||||
f.addWhere("movies_images.back_image IS NULL")
|
||||
case "scenes":
|
||||
f.addJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id")
|
||||
f.addWhere("movies_scenes.scene_id IS NULL")
|
||||
default:
|
||||
f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: movieTable,
|
||||
foreignTable: studioTable,
|
||||
foreignFK: studioIDColumn,
|
||||
derivedTable: "studio",
|
||||
parentFK: "parent_id",
|
||||
}
|
||||
|
||||
return h.handler(studios)
|
||||
}
|
||||
|
||||
func (qb *movieQueryBuilder) getMovieSort(findFilter *models.FindFilterType) string {
|
||||
var sort string
|
||||
var direction string
|
||||
|
|
|
|||
|
|
@ -76,11 +76,12 @@ func TestMovieFindByNames(t *testing.T) {
|
|||
func TestMovieQueryStudio(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
mqb := r.Movie()
|
||||
studioCriterion := models.MultiCriterionInput{
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithMovie]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
movieFilter := models.MovieFilterType{
|
||||
|
|
@ -97,11 +98,12 @@ func TestMovieQueryStudio(t *testing.T) {
|
|||
// ensure id is correct
|
||||
assert.Equal(t, movieIDs[movieIdxWithStudio], movies[0].ID)
|
||||
|
||||
studioCriterion = models.MultiCriterionInput{
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithMovie]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
q := getMovieStringValue(movieIdxWithStudio, titleField)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package sqlite
|
|||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
|
@ -16,6 +17,7 @@ type queryBuilder struct {
|
|||
whereClauses []string
|
||||
havingClauses []string
|
||||
args []interface{}
|
||||
withClauses []string
|
||||
|
||||
sortAndPagination string
|
||||
|
||||
|
|
@ -30,7 +32,7 @@ func (qb queryBuilder) executeFind() ([]int, int, error) {
|
|||
body := qb.body
|
||||
body += qb.joins.toSQL()
|
||||
|
||||
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses)
|
||||
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses)
|
||||
}
|
||||
|
||||
func (qb queryBuilder) executeCount() (int, error) {
|
||||
|
|
@ -41,8 +43,13 @@ func (qb queryBuilder) executeCount() (int, error) {
|
|||
body := qb.body
|
||||
body += qb.joins.toSQL()
|
||||
|
||||
withClause := ""
|
||||
if len(qb.withClauses) > 0 {
|
||||
withClause = "WITH " + strings.Join(qb.withClauses, ", ") + " "
|
||||
}
|
||||
|
||||
body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)
|
||||
countQuery := qb.repository.buildCountQuery(body)
|
||||
countQuery := withClause + qb.repository.buildCountQuery(body)
|
||||
return qb.repository.runCountQuery(countQuery, qb.args)
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +69,14 @@ func (qb *queryBuilder) addHaving(clauses ...string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) addWith(clauses ...string) {
|
||||
for _, clause := range clauses {
|
||||
if len(clause) > 0 {
|
||||
qb.withClauses = append(qb.withClauses, clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) addArg(args ...interface{}) {
|
||||
qb.args = append(qb.args, args...)
|
||||
}
|
||||
|
|
@ -87,7 +102,17 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) {
|
|||
return
|
||||
}
|
||||
|
||||
clause, args := f.generateWhereClauses()
|
||||
clause, args := f.generateWithClauses()
|
||||
if len(clause) > 0 {
|
||||
qb.addWith(clause)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
// WITH clause always comes first and thus precedes alk args
|
||||
qb.args = append(args, qb.args...)
|
||||
}
|
||||
|
||||
clause, args = f.generateWhereClauses()
|
||||
if len(clause) > 0 {
|
||||
qb.addWhere(clause)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,11 +246,16 @@ func (r *repository) buildQueryBody(body string, whereClauses []string, havingCl
|
|||
return body
|
||||
}
|
||||
|
||||
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) {
|
||||
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string, withClauses []string) ([]int, int, error) {
|
||||
body = r.buildQueryBody(body, whereClauses, havingClauses)
|
||||
|
||||
countQuery := r.buildCountQuery(body)
|
||||
idsQuery := body + sortAndPagination
|
||||
withClause := ""
|
||||
if len(withClauses) > 0 {
|
||||
withClause = "WITH " + strings.Join(withClauses, ", ") + " "
|
||||
}
|
||||
|
||||
countQuery := withClause + r.buildCountQuery(body)
|
||||
idsQuery := withClause + body + sortAndPagination
|
||||
|
||||
// Perform query and fetch result
|
||||
logger.Tracef("SQL: %s, args: %v", idsQuery, args)
|
||||
|
|
|
|||
|
|
@ -588,11 +588,14 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *
|
|||
return h.handler(performerCount)
|
||||
}
|
||||
|
||||
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
addJoinsFunc := func(f *filterBuilder) {
|
||||
f.addJoin("studios", "studio", "studio.id = scenes.studio_id")
|
||||
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: sceneTable,
|
||||
foreignTable: studioTable,
|
||||
foreignFK: studioIDColumn,
|
||||
derivedTable: "studio",
|
||||
parentFK: "parent_id",
|
||||
}
|
||||
h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc)
|
||||
|
||||
return h.handler(studios)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ func (qb *sceneMarkerQueryBuilder) Query(sceneMarkerFilter *models.SceneMarkerFi
|
|||
}
|
||||
|
||||
sortAndPagination := qb.getSceneMarkerSort(findFilter) + getPagination(findFilter)
|
||||
idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses)
|
||||
idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses, []string{})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1070,11 +1070,12 @@ func TestSceneQueryPerformerTags(t *testing.T) {
|
|||
func TestSceneQueryStudio(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Scene()
|
||||
studioCriterion := models.MultiCriterionInput{
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithScene]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
sceneFilter := models.SceneFilterType{
|
||||
|
|
@ -1088,11 +1089,12 @@ func TestSceneQueryStudio(t *testing.T) {
|
|||
// ensure id is correct
|
||||
assert.Equal(t, sceneIDs[sceneIdxWithStudio], scenes[0].ID)
|
||||
|
||||
studioCriterion = models.MultiCriterionInput{
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithScene]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
q := getSceneStringValue(sceneIdxWithStudio, titleField)
|
||||
|
|
@ -1107,6 +1109,64 @@ func TestSceneQueryStudio(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestSceneQueryStudioDepth(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Scene()
|
||||
studioCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
sceneFilter := models.SceneFilterType{
|
||||
Studios: &studioCriterion,
|
||||
}
|
||||
|
||||
scenes := queryScene(t, sqb, &sceneFilter, nil)
|
||||
assert.Len(t, scenes, 1)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
|
||||
scenes = queryScene(t, sqb, &sceneFilter, nil)
|
||||
assert.Len(t, scenes, 0)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
scenes = queryScene(t, sqb, &sceneFilter, nil)
|
||||
assert.Len(t, scenes, 1)
|
||||
|
||||
// ensure id is correct
|
||||
assert.Equal(t, sceneIDs[sceneIdxWithGrandChildStudio], scenes[0].ID)
|
||||
|
||||
studioCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(studioIDs[studioIdxWithGrandChild]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
Depth: 2,
|
||||
}
|
||||
|
||||
q := getSceneStringValue(sceneIdxWithGrandChildStudio, titleField)
|
||||
findFilter := models.FindFilterType{
|
||||
Q: &q,
|
||||
}
|
||||
|
||||
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
|
||||
assert.Len(t, scenes, 0)
|
||||
|
||||
studioCriterion.Depth = 1
|
||||
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
|
||||
assert.Len(t, scenes, 1)
|
||||
|
||||
studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}
|
||||
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
|
||||
assert.Len(t, scenes, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestSceneQueryMovies(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Scene()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const (
|
|||
sceneIdxWithPerformerTwoTags
|
||||
sceneIdxWithSpacedName
|
||||
sceneIdxWithStudioPerformer
|
||||
sceneIdxWithGrandChildStudio
|
||||
// new indexes above
|
||||
lastSceneIdx
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ const (
|
|||
imageIdxInZip // TODO - not implemented
|
||||
imageIdxWithPerformerTag
|
||||
imageIdxWithPerformerTwoTags
|
||||
imageIdxWithGrandChildStudio
|
||||
// new indexes above
|
||||
totalImages
|
||||
)
|
||||
|
|
@ -125,6 +127,7 @@ const (
|
|||
galleryIdxWithPerformerTag
|
||||
galleryIdxWithPerformerTwoTags
|
||||
galleryIdxWithStudioPerformer
|
||||
galleryIdxWithGrandChildStudio
|
||||
// new indexes above
|
||||
lastGalleryIdx
|
||||
|
||||
|
|
@ -169,6 +172,9 @@ const (
|
|||
studioIdxWithScenePerformer
|
||||
studioIdxWithImagePerformer
|
||||
studioIdxWithGalleryPerformer
|
||||
studioIdxWithGrandChild
|
||||
studioIdxWithParentAndChild
|
||||
studioIdxWithGrandParent
|
||||
// new indexes above
|
||||
// studios with dup names start from the end
|
||||
studioIdxWithDupName
|
||||
|
|
@ -241,6 +247,7 @@ var (
|
|||
{sceneIdx1WithStudio, studioIdxWithTwoScenes},
|
||||
{sceneIdx2WithStudio, studioIdxWithTwoScenes},
|
||||
{sceneIdxWithStudioPerformer, studioIdxWithScenePerformer},
|
||||
{sceneIdxWithGrandChildStudio, studioIdxWithGrandParent},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -257,6 +264,7 @@ var (
|
|||
{imageIdx1WithStudio, studioIdxWithTwoImages},
|
||||
{imageIdx2WithStudio, studioIdxWithTwoImages},
|
||||
{imageIdxWithStudioPerformer, studioIdxWithImagePerformer},
|
||||
{imageIdxWithGrandChildStudio, studioIdxWithGrandParent},
|
||||
}
|
||||
imageTagLinks = [][2]int{
|
||||
{imageIdxWithTag, tagIdxWithImage},
|
||||
|
|
@ -292,6 +300,7 @@ var (
|
|||
{galleryIdx1WithStudio, studioIdxWithTwoGalleries},
|
||||
{galleryIdx2WithStudio, studioIdxWithTwoGalleries},
|
||||
{galleryIdxWithStudioPerformer, studioIdxWithGalleryPerformer},
|
||||
{galleryIdxWithGrandChildStudio, studioIdxWithGrandParent},
|
||||
}
|
||||
|
||||
galleryTagLinks = [][2]int{
|
||||
|
|
@ -310,6 +319,8 @@ var (
|
|||
var (
|
||||
studioParentLinks = [][2]int{
|
||||
{studioIdxWithChildStudio, studioIdxWithParentStudio},
|
||||
{studioIdxWithGrandChild, studioIdxWithParentAndChild},
|
||||
{studioIdxWithParentAndChild, studioIdxWithGrandParent},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -359,9 +359,10 @@ func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriteri
|
|||
pp := 0
|
||||
|
||||
_, count, err := r.Image().Query(&models.ImageFilterType{
|
||||
Studios: &models.MultiCriterionInput{
|
||||
Studios: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(studio.ID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
},
|
||||
}, &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
|
|
@ -409,9 +410,10 @@ func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCri
|
|||
pp := 0
|
||||
|
||||
_, count, err := r.Gallery().Query(&models.GalleryFilterType{
|
||||
Studios: &models.MultiCriterionInput{
|
||||
Studios: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(studio.ID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: 0,
|
||||
},
|
||||
}, &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
### ✨ New Features
|
||||
* Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397))
|
||||
* Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412))
|
||||
* Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393))
|
||||
* Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379))
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ import {
|
|||
DurationCriterion,
|
||||
CriterionValue,
|
||||
Criterion,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { NoneCriterion } from "src/models/list-filter/criteria/none";
|
||||
import { makeCriteria } from "src/models/list-filter/criteria/factory";
|
||||
import { ListFilterOptions } from "src/models/list-filter/filter-options";
|
||||
import { useIntl } from "react-intl";
|
||||
import { CriterionType } from "src/models/list-filter/types";
|
||||
import { defineMessages, useIntl } from "react-intl";
|
||||
import {
|
||||
criterionIsHierarchicalLabelValue,
|
||||
CriterionType,
|
||||
} from "src/models/list-filter/types";
|
||||
|
||||
interface IAddFilterProps {
|
||||
onAddCriterion: (
|
||||
|
|
@ -39,6 +43,13 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
|
||||
const intl = useIntl();
|
||||
|
||||
const messages = defineMessages({
|
||||
studio_depth: {
|
||||
id: "studio_depth",
|
||||
defaultMessage: "Levels (empty for all)",
|
||||
},
|
||||
});
|
||||
|
||||
// configure keyboard shortcuts
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("f", () => setIsOpen(true));
|
||||
|
|
@ -183,7 +194,29 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (criterion.options) {
|
||||
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
|
||||
if (criterion.criterionOption.value !== "studios") return;
|
||||
|
||||
return (
|
||||
<FilterSelect
|
||||
type={criterion.criterionOption.value}
|
||||
isMulti
|
||||
onSelect={(items) => {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.value.items = items.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.name!,
|
||||
}));
|
||||
setCriterion(newCriterion);
|
||||
}}
|
||||
ids={criterion.value.items.map((labeled) => labeled.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
criterion.options &&
|
||||
!criterionIsHierarchicalLabelValue(criterion.value)
|
||||
) {
|
||||
defaultValue.current = criterion.value;
|
||||
return (
|
||||
<Form.Control
|
||||
|
|
@ -219,10 +252,53 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
/>
|
||||
);
|
||||
}
|
||||
function renderAdditional() {
|
||||
if (criterion instanceof IHierarchicalLabeledIdCriterion) {
|
||||
return (
|
||||
<>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
checked={criterion.value.depth !== 0}
|
||||
label="Include child studios"
|
||||
onChange={() => {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.value.depth =
|
||||
newCriterion.value.depth !== 0 ? 0 : -1;
|
||||
setCriterion(newCriterion);
|
||||
}}
|
||||
/>
|
||||
</Form.Group>
|
||||
{criterion.value.depth !== 0 && (
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type="number"
|
||||
placeholder={intl.formatMessage(messages.studio_depth)}
|
||||
onChange={(e) => {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.value.depth = e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: -1;
|
||||
setCriterion(newCriterion);
|
||||
}}
|
||||
defaultValue={
|
||||
criterion.value && criterion.value.depth !== -1
|
||||
? criterion.value.depth
|
||||
: ""
|
||||
}
|
||||
min="1"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Group>{renderModifier()}</Form.Group>
|
||||
<Form.Group>{renderSelect()}</Form.Group>
|
||||
{renderAdditional()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
|
|||
studio,
|
||||
}) => {
|
||||
const studioCriterion = new StudiosCriterion();
|
||||
studioCriterion.value = [
|
||||
{ id: studio.id!, label: studio.name || `Studio ${studio.id}` },
|
||||
];
|
||||
studioCriterion.value = {
|
||||
items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }],
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
const extraCriteria = {
|
||||
scenes: [studioCriterion],
|
||||
|
|
|
|||
|
|
@ -17,18 +17,21 @@ export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => {
|
|||
) {
|
||||
// add the studio if not present
|
||||
if (
|
||||
!studioCriterion.value.find((p) => {
|
||||
!studioCriterion.value.items.find((p) => {
|
||||
return p.id === studio.id;
|
||||
})
|
||||
) {
|
||||
studioCriterion.value.push(studioValue);
|
||||
studioCriterion.value.items.push(studioValue);
|
||||
}
|
||||
|
||||
studioCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
studioCriterion = new StudiosCriterion();
|
||||
studioCriterion.value = [studioValue];
|
||||
studioCriterion.value = {
|
||||
items: [studioValue],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(studioCriterion);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"scenes_updated_at": "Scene Updated At",
|
||||
"seconds": "Seconds",
|
||||
"stash_id": "Stash ID",
|
||||
"studio_depth": "Levels (empty for all)",
|
||||
"studios": "Studios",
|
||||
"tag_count": "Tag Count",
|
||||
"tags": "Tags",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { IntlShape } from "react-intl";
|
||||
import {
|
||||
CriterionModifier,
|
||||
HierarchicalMultiCriterionInput,
|
||||
MultiCriterionInput,
|
||||
} from "src/core/generated-graphql";
|
||||
import DurationUtils from "src/utils/duration";
|
||||
|
|
@ -12,10 +13,15 @@ import {
|
|||
ILabeledId,
|
||||
ILabeledValue,
|
||||
IOptionType,
|
||||
IHierarchicalLabelValue,
|
||||
} from "../types";
|
||||
|
||||
type Option = string | number | IOptionType;
|
||||
export type CriterionValue = string | number | ILabeledId[];
|
||||
export type CriterionValue =
|
||||
| string
|
||||
| number
|
||||
| ILabeledId[]
|
||||
| IHierarchicalLabelValue;
|
||||
|
||||
// V = criterion value type
|
||||
export abstract class Criterion<V extends CriterionValue> {
|
||||
|
|
@ -305,6 +311,69 @@ export abstract class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
|
|||
}
|
||||
}
|
||||
|
||||
export abstract 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() {
|
||||
return {
|
||||
items: this.value.items.map((o) => {
|
||||
return encodeILabeledId(o);
|
||||
}),
|
||||
depth: this.value.depth,
|
||||
};
|
||||
}
|
||||
|
||||
protected toCriterionInput(): HierarchicalMultiCriterionInput {
|
||||
return {
|
||||
value: this.value.items.map((v) => v.id),
|
||||
modifier: this.modifier,
|
||||
depth: this.value.depth,
|
||||
};
|
||||
}
|
||||
|
||||
public getLabelValue(): string {
|
||||
const labels = this.value.items.map((v) => v.label).join(", ");
|
||||
|
||||
if (this.value.depth === 0) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
const encodedCriterion = {
|
||||
type: this.criterionOption.value,
|
||||
value: this.encodeValue(),
|
||||
modifier: this.modifier,
|
||||
};
|
||||
return JSON.stringify(encodedCriterion);
|
||||
}
|
||||
|
||||
constructor(type: CriterionOption, includeAll: boolean) {
|
||||
super(type);
|
||||
|
||||
if (!includeAll) {
|
||||
this.modifier = CriterionModifier.Includes;
|
||||
this.modifierOptions = [
|
||||
Criterion.getModifierOption(CriterionModifier.Includes),
|
||||
Criterion.getModifierOption(CriterionModifier.Excludes),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MandatoryNumberCriterion extends NumberCriterion {
|
||||
public modifierOptions = [
|
||||
Criterion.getModifierOption(CriterionModifier.Equals),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { CriterionOption, ILabeledIdCriterion } from "./criterion";
|
||||
|
||||
abstract class AbstractStudiosCriterion extends ILabeledIdCriterion {
|
||||
constructor(type: CriterionOption) {
|
||||
super(type, false);
|
||||
}
|
||||
}
|
||||
import {
|
||||
CriterionOption,
|
||||
IHierarchicalLabeledIdCriterion,
|
||||
ILabeledIdCriterion,
|
||||
} from "./criterion";
|
||||
|
||||
export const StudiosCriterionOption = new CriterionOption("studios", "studios");
|
||||
export class StudiosCriterion extends AbstractStudiosCriterion {
|
||||
export class StudiosCriterion extends IHierarchicalLabeledIdCriterion {
|
||||
constructor() {
|
||||
super(StudiosCriterionOption);
|
||||
super(StudiosCriterionOption, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -18,8 +16,8 @@ export const ParentStudiosCriterionOption = new CriterionOption(
|
|||
"parent_studios",
|
||||
"parents"
|
||||
);
|
||||
export class ParentStudiosCriterion extends AbstractStudiosCriterion {
|
||||
export class ParentStudiosCriterion extends ILabeledIdCriterion {
|
||||
constructor() {
|
||||
super(ParentStudiosCriterionOption);
|
||||
super(ParentStudiosCriterionOption, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ export interface ILabeledValue {
|
|||
value: string;
|
||||
}
|
||||
|
||||
export interface IHierarchicalLabelValue {
|
||||
items: ILabeledId[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function criterionIsHierarchicalLabelValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any
|
||||
): value is IHierarchicalLabelValue {
|
||||
return typeof value === "object" && "items" in value && "depth" in value;
|
||||
}
|
||||
|
||||
export function encodeILabeledId(o: ILabeledId) {
|
||||
// escape " and \ and by encoding to JSON so that it encodes to JSON correctly down the line
|
||||
const adjustedLabel = JSON.stringify(o.label).slice(1, -1);
|
||||
|
|
|
|||
|
|
@ -85,9 +85,10 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
|||
if (!studio.id) return "#";
|
||||
const filter = new ListFilterModel();
|
||||
const criterion = new StudiosCriterion();
|
||||
criterion.value = [
|
||||
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
|
||||
];
|
||||
criterion.value = {
|
||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/scenes?${filter.makeQueryParameters()}`;
|
||||
};
|
||||
|
|
@ -96,9 +97,10 @@ const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
|||
if (!studio.id) return "#";
|
||||
const filter = new ListFilterModel();
|
||||
const criterion = new StudiosCriterion();
|
||||
criterion.value = [
|
||||
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
|
||||
];
|
||||
criterion.value = {
|
||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/images?${filter.makeQueryParameters()}`;
|
||||
};
|
||||
|
|
@ -107,9 +109,10 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
|||
if (!studio.id) return "#";
|
||||
const filter = new ListFilterModel();
|
||||
const criterion = new StudiosCriterion();
|
||||
criterion.value = [
|
||||
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
|
||||
];
|
||||
criterion.value = {
|
||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/galleries?${filter.makeQueryParameters()}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue