mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 18:35:26 -05:00
Compare commits
5 Commits
docs-capti
...
docs-fix-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48efc3e9c6 | ||
|
|
7eff7f02d0 | ||
|
|
661d2f64bb | ||
|
|
d0a7b09bf3 | ||
|
|
27bc6c8fca |
@@ -16,6 +16,16 @@ type Query {
|
||||
ids: [ID!]
|
||||
): FindFilesResultType!
|
||||
|
||||
"Find a file by its id or path"
|
||||
findFolder(id: ID, path: String): Folder!
|
||||
|
||||
"Queries for Files"
|
||||
findFolders(
|
||||
folder_filter: FolderFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindFoldersResultType!
|
||||
|
||||
"Find a scene by ID or Checksum"
|
||||
findScene(id: ID, checksum: String): Scene
|
||||
findSceneByHash(input: SceneHashInput!): Scene
|
||||
|
||||
@@ -10,7 +10,7 @@ type Folder {
|
||||
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
parent_folder: Folder
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
@@ -176,3 +176,8 @@ type FindFilesResultType {
|
||||
|
||||
files: [BaseFile!]!
|
||||
}
|
||||
|
||||
type FindFoldersResultType {
|
||||
count: Int!
|
||||
folders: [Folder!]!
|
||||
}
|
||||
|
||||
@@ -691,6 +691,7 @@ input FileFilterType {
|
||||
dir: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
@@ -721,6 +722,32 @@ input FileFilterType {
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input FolderFilterType {
|
||||
AND: FolderFilterType
|
||||
OR: FolderFilterType
|
||||
NOT: FolderFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
gallery_count: IntCriterionInput
|
||||
|
||||
"Filter by files that meet this criteria"
|
||||
files_filter: FileFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input VideoFileFilterInput {
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
|
||||
100
internal/api/resolver_query_find_folder.go
Normal file
100
internal/api/resolver_query_find_folder.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
var ret *models.Folder
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Folder
|
||||
var err error
|
||||
switch {
|
||||
case id != nil:
|
||||
idInt, err := strconv.Atoi(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret, err = qb.Find(ctx, models.FolderID(idInt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("folder not found")
|
||||
}
|
||||
default:
|
||||
return errors.New("either id or path must be provided")
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindFolders(
|
||||
ctx context.Context,
|
||||
folderFilter *models.FolderFilterType,
|
||||
filter *models.FindFilterType,
|
||||
ids []string,
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folderIDs = models.FolderIDsFromInts(folderIDsInt)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var folders []*models.Folder
|
||||
var err error
|
||||
|
||||
fields := collectQueryFields(ctx)
|
||||
result := &models.FolderQueryResult{}
|
||||
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err = r.repository.Folder.FindMany(ctx, folderIDs)
|
||||
if err == nil {
|
||||
result.Count = len(folders)
|
||||
}
|
||||
} else {
|
||||
result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: filter,
|
||||
Count: fields.Has("count"),
|
||||
},
|
||||
FolderFilter: folderFilter,
|
||||
})
|
||||
if err == nil {
|
||||
folders, err = result.Resolve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = &FindFoldersResultType{
|
||||
Count: result.Count,
|
||||
Folders: folders,
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type FileFilterType struct {
|
||||
Basename *StringCriterionInput `json:"basename"`
|
||||
Dir *StringCriterionInput `json:"dir"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
|
||||
92
pkg/models/folder.go
Normal file
92
pkg/models/folder.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FolderQueryOptions struct {
|
||||
QueryOptions
|
||||
FolderFilter *FolderFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FolderFilterType struct {
|
||||
OperatorFilter[FolderFilterType]
|
||||
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
// Filter by parent directory path
|
||||
Dir *StringCriterionInput `json:"dir,omitempty"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
|
||||
// Filter by modification time
|
||||
ModTime *TimestampCriterionInput `json:"mod_time,omitempty"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"`
|
||||
// Filter by files that meet this criteria
|
||||
FilesFilter *FileFilterType `json:"files_filter,omitempty"`
|
||||
// Filter by related galleries that meet this criteria
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"`
|
||||
// Filter by creation time
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"`
|
||||
// Filter by last update time
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func PathsFolderFilter(paths []string) *FileFilterType {
|
||||
if paths == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
var ret *FileFilterType
|
||||
var or *FileFilterType
|
||||
for _, p := range paths {
|
||||
newOr := &FileFilterType{}
|
||||
if or != nil {
|
||||
or.Or = newOr
|
||||
} else {
|
||||
ret = newOr
|
||||
}
|
||||
|
||||
or = newOr
|
||||
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
or.Path = &StringCriterionInput{
|
||||
Modifier: CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
type FolderQueryResult struct {
|
||||
QueryResult[FolderID]
|
||||
|
||||
getter FolderGetter
|
||||
folders []*Folder
|
||||
resolveErr error
|
||||
}
|
||||
|
||||
func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult {
|
||||
return &FolderQueryResult{
|
||||
getter: folderGetter,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) {
|
||||
// cache results
|
||||
if r.folders == nil && r.resolveErr == nil {
|
||||
r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs)
|
||||
}
|
||||
return r.folders, r.resolveErr
|
||||
}
|
||||
@@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, options
|
||||
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
ret := _m.Called(ctx, options)
|
||||
|
||||
var r0 *models.FolderQueryResult
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok {
|
||||
r0 = rf(ctx, options)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.FolderQueryResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok {
|
||||
r1 = rf(ctx, options)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, f
|
||||
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
|
||||
ret := _m.Called(ctx, f)
|
||||
|
||||
@@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FolderIDsFromInts(ids []int) []FolderID {
|
||||
ret := make([]FolderID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FolderID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Folder represents a folder in the file system.
|
||||
type Folder struct {
|
||||
ID FolderID `json:"id"`
|
||||
|
||||
@@ -17,6 +17,10 @@ type FolderFinder interface {
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
type FolderQueryer interface {
|
||||
Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error)
|
||||
}
|
||||
|
||||
type FolderCounter interface {
|
||||
CountAllInPaths(ctx context.Context, p []string) (int, error)
|
||||
}
|
||||
@@ -48,6 +52,7 @@ type FolderFinderDestroyer interface {
|
||||
// FolderReader provides all methods to read folders.
|
||||
type FolderReader interface {
|
||||
FolderFinder
|
||||
FolderQueryer
|
||||
FolderCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ type FileStore struct {
|
||||
func NewFileStore() *FileStore {
|
||||
return &FileStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: fileTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(fileFilter.ZipFile),
|
||||
|
||||
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
|
||||
qb.imageCountCriterionHandler(fileFilter.ImageCount),
|
||||
@@ -106,6 +107,43 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestFileQuery(t *testing.T) {
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FileFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []int
|
||||
includeIDs []models.FileID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
@@ -52,7 +52,7 @@ func TestFileQuery(t *testing.T) {
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
@@ -65,7 +65,20 @@ func TestFileQuery(t *testing.T) {
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FileFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
@@ -86,15 +99,18 @@ func TestFileQuery(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDs(sceneIDs, tt.includeIdxs)
|
||||
include = append(include, tt.includeIDs...)
|
||||
exclude := indexesToIDs(sceneIDs, tt.excludeIdxs)
|
||||
include := indexesToIDPtrs(fileIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FileID(i))
|
||||
assert.Contains(results.IDs, models.FileID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FileID(e))
|
||||
assert.NotContains(results.IDs, models.FileID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const folderTable = "folders"
|
||||
const folderIDColumn = "folder_id"
|
||||
|
||||
type folderRow struct {
|
||||
ID models.FolderID `db:"id" goqu:"skipinsert"`
|
||||
@@ -83,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder {
|
||||
return ret
|
||||
}
|
||||
|
||||
type folderRepositoryType struct {
|
||||
repository
|
||||
|
||||
galleries repository
|
||||
}
|
||||
|
||||
var (
|
||||
folderRepository = folderRepositoryType{
|
||||
repository: repository{
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
galleries: repository{
|
||||
tableName: galleryTable,
|
||||
idColumn: folderIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FolderStore struct {
|
||||
repository
|
||||
|
||||
@@ -92,7 +112,7 @@ type FolderStore struct {
|
||||
func NewFolderStore() *FolderStore {
|
||||
return &FolderStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
@@ -360,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil
|
||||
|
||||
return qb.getMany(ctx, q)
|
||||
}
|
||||
|
||||
func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error {
|
||||
const and = "AND"
|
||||
const or = "OR"
|
||||
const not = "NOT"
|
||||
|
||||
if fileFilter.And != nil {
|
||||
if fileFilter.Or != nil {
|
||||
return illegalFilterCombination(and, or)
|
||||
}
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(and, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.And)
|
||||
}
|
||||
|
||||
if fileFilter.Or != nil {
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(or, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.Or)
|
||||
}
|
||||
|
||||
if fileFilter.Not != nil {
|
||||
return qb.validateFilter(fileFilter.Not)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder {
|
||||
query := &filterBuilder{}
|
||||
|
||||
if folderFilter.And != nil {
|
||||
query.and(qb.makeFilter(ctx, folderFilter.And))
|
||||
}
|
||||
if folderFilter.Or != nil {
|
||||
query.or(qb.makeFilter(ctx, folderFilter.Or))
|
||||
}
|
||||
if folderFilter.Not != nil {
|
||||
query.not(qb.makeFilter(ctx, folderFilter.Not))
|
||||
}
|
||||
|
||||
filter := filterBuilderFromHandler(ctx, &folderFilterHandler{
|
||||
folderFilter: folderFilter,
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
folderFilter := options.FolderFilter
|
||||
findFilter := options.FindFilter
|
||||
|
||||
if folderFilter == nil {
|
||||
folderFilter = &models.FolderFilterType{}
|
||||
}
|
||||
if findFilter == nil {
|
||||
findFilter = &models.FindFilterType{}
|
||||
}
|
||||
|
||||
query := qb.newQuery()
|
||||
|
||||
distinctIDs(&query, folderTable)
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"folders.path"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
if err := qb.validateFilter(folderFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := qb.makeFilter(ctx, folderFilter)
|
||||
|
||||
if err := query.addFilter(filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := qb.setQuerySort(&query, findFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.sortAndPagination += getPagination(findFilter)
|
||||
|
||||
result, err := qb.queryGroupedFields(ctx, options, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying aggregate fields: %w", err)
|
||||
}
|
||||
|
||||
idsResult, err := query.findIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error finding IDs: %w", err)
|
||||
}
|
||||
|
||||
result.IDs = make([]models.FolderID, len(idsResult))
|
||||
for i, id := range idsResult {
|
||||
result.IDs[i] = models.FolderID(id)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) {
|
||||
if !options.Count {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFolderQueryResult(qb), nil
|
||||
}
|
||||
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := models.NewFolderQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var folderSortOptions = sortOptions{
|
||||
"created_at",
|
||||
"id",
|
||||
"path",
|
||||
"random",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {
|
||||
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
|
||||
return nil
|
||||
}
|
||||
sort := findFilter.GetSort("path")
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := folderSortOptions.validateSort(sort); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
direction := findFilter.GetDirection()
|
||||
query.sortAndPagination += getSort(sort, direction, "folders")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
150
pkg/sqlite/folder_filter.go
Normal file
150
pkg/sqlite/folder_filter.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type folderFilterHandler struct {
|
||||
folderFilter *models.FolderFilterType
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) validate() error {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := folderFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &folderFilterHandler{folderFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := folderFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &folderFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) criterionHandler() criterionHandler {
|
||||
folderFilter := qb.folderFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(folderFilter.Path, "folders.path"),
|
||||
×tampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(folderFilter.ZipFile),
|
||||
|
||||
qb.galleryCountCriterionHandler(folderFilter.GalleryCount),
|
||||
|
||||
×tampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil},
|
||||
×tampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries.id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
folderRepository.galleries.innerJoin(f, "", "folders.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: folderTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if galleryCount != nil {
|
||||
f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id")
|
||||
clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount)
|
||||
|
||||
f.addHaving(clause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
pkg/sqlite/folder_filter_test.go
Normal file
95
pkg/sqlite/folder_filter_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFolderQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FolderFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []models.FolderID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FolderFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getFolderPath(folderIdxWithSubFolder, nil),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FolderFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FolderFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxInZip},
|
||||
excludeIdxs: []int{folderIdxForObjectFiles},
|
||||
},
|
||||
// TODO - add more tests for other folder filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
FolderFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDPtrs(folderIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FolderID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FolderID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int {
|
||||
return ids[idx]
|
||||
}
|
||||
|
||||
func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
|
||||
ret := make([]*T, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
ret[i] = indexToIDPtr(ids, idx)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func indexToIDPtr[T any](ids []T, idx int) *T {
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
return &ids[idx]
|
||||
}
|
||||
|
||||
func indexFromID(ids []int, id int) int {
|
||||
for i, v := range ids {
|
||||
if v == id {
|
||||
@@ -675,7 +691,9 @@ func populateDB() error {
|
||||
return fmt.Errorf("creating files: %w", err)
|
||||
}
|
||||
|
||||
// TODO - link folders to zip files
|
||||
if err := linkFoldersToZip(ctx); err != nil {
|
||||
return fmt.Errorf("linking folders to zip files: %w", err)
|
||||
}
|
||||
|
||||
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating tags: %s", err.Error())
|
||||
@@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func linkFoldersToZip(ctx context.Context) error {
|
||||
// link folders to zip files
|
||||
for folderIdx, fileIdx := range folderZipFiles {
|
||||
folderID := folderIDs[folderIdx]
|
||||
fileID := fileIDs[fileIdx]
|
||||
|
||||
f, err := db.Folder.Find(ctx, folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID)
|
||||
}
|
||||
|
||||
f.ZipFileID = &fileID
|
||||
|
||||
if err := db.Folder.Update(ctx, f); err != nil {
|
||||
return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileBaseName(index int) string {
|
||||
return getPrefixedStringValue("file", index, "basename")
|
||||
}
|
||||
|
||||
@@ -101,8 +101,12 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (criteria.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
|
||||
<div className="wrap-tags filter-tags">
|
||||
{criteria.map(renderFilterTags)}
|
||||
{criteria.length >= 3 && (
|
||||
<Button
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
count?: number;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||
filter,
|
||||
count = 0,
|
||||
onClick,
|
||||
title,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
|
||||
if (!title) {
|
||||
title = intl.formatMessage({ id: "search_filter.edit_filter" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="filter-button"
|
||||
onClick={onClick}
|
||||
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
|
||||
title={title}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
{count ? (
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SidebarSection, SidebarToolbar } from "src/components/Shared/Sidebar";
|
||||
import { SidebarSection } from "src/components/Shared/Sidebar";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterButton } from "./FilterButton";
|
||||
import { SearchTermInput } from "../ListFilter";
|
||||
import { SidebarSavedFilterList } from "../SavedFilterList";
|
||||
import { View } from "../views";
|
||||
import useFocus from "src/utils/focus";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
export const FilteredSidebarToolbar: React.FC<{
|
||||
onClose?: () => void;
|
||||
}> = ({ onClose, children }) => {
|
||||
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
|
||||
};
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
export const FilteredSidebarHeader: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: () => void;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
|
||||
}> = ({ sidebarOpen, showEditFilter, filter, setFilter, view }) => {
|
||||
const focus = useFocus();
|
||||
const [, setFocus] = focus;
|
||||
|
||||
@@ -37,15 +30,24 @@ export const FilteredSidebarHeader: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarToolbar onClose={onClose} />
|
||||
<div className="sidebar-search-container">
|
||||
<SearchTermInput
|
||||
filter={filter}
|
||||
onFilterUpdate={setFilter}
|
||||
focus={focus}
|
||||
/>
|
||||
<FilterButton onClick={() => showEditFilter()} filter={filter} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
className="edit-filter-button"
|
||||
size="sm"
|
||||
onClick={() => showEditFilter()}
|
||||
>
|
||||
<FormattedMessage id="search_filter.edit_filter" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SidebarSection
|
||||
className="sidebar-saved-filters"
|
||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useDebounce } from "src/hooks/debounce";
|
||||
import { View } from "./views";
|
||||
import { ClearableInput } from "../Shared/ClearableInput";
|
||||
import { useStopWheelScroll } from "src/utils/form";
|
||||
import { ISortByOption } from "src/models/list-filter/filter-options";
|
||||
|
||||
export function useDebouncedSearchInput(
|
||||
filter: ListFilterModel,
|
||||
@@ -230,6 +231,94 @@ export const PageSizeSelector: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const SortBySelect: React.FC<{
|
||||
className?: string;
|
||||
sortBy: string | undefined;
|
||||
sortDirection: SortDirectionEnum;
|
||||
options: ISortByOption[];
|
||||
onChangeSortBy: (eventKey: string | null) => void;
|
||||
onChangeSortDirection: () => void;
|
||||
onReshuffleRandomSort: () => void;
|
||||
}> = ({
|
||||
className,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
options,
|
||||
onChangeSortBy,
|
||||
onChangeSortDirection,
|
||||
onReshuffleRandomSort,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const currentSortBy = options.find((o) => o.value === sortBy);
|
||||
|
||||
function renderSortByOptions() {
|
||||
return options
|
||||
.map((o) => {
|
||||
return {
|
||||
message: intl.formatMessage({ id: o.messageID }),
|
||||
value: o.value,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.message.localeCompare(b.message))
|
||||
.map((option) => (
|
||||
<Dropdown.Item
|
||||
onSelect={onChangeSortBy}
|
||||
key={option.value}
|
||||
className="bg-secondary text-white"
|
||||
eventKey={option.value}
|
||||
>
|
||||
{option.message}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup} className={className}>
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
? intl.formatMessage({ id: currentSortBy.messageID })
|
||||
: ""}
|
||||
</Dropdown.Toggle>
|
||||
</InputGroup.Prepend>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{renderSortByOptions()}
|
||||
</Dropdown.Menu>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-direction-tooltip">
|
||||
{sortDirection === SortDirectionEnum.Asc
|
||||
? intl.formatMessage({ id: "ascending" })
|
||||
: intl.formatMessage({ id: "descending" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onChangeSortDirection}>
|
||||
<Icon
|
||||
icon={
|
||||
sortDirection === SortDirectionEnum.Asc ? faCaretUp : faCaretDown
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
{sortBy === "random" && (
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-reshuffle-tooltip">
|
||||
{intl.formatMessage({ id: "actions.reshuffle" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onReshuffleRandomSort}>
|
||||
<Icon icon={faRandom} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
interface IListFilterProps {
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
filter: ListFilterModel;
|
||||
@@ -247,8 +336,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
}) => {
|
||||
const filterOptions = filter.options;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("r", () => onReshuffleRandomSort());
|
||||
|
||||
@@ -289,32 +376,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
function renderSortByOptions() {
|
||||
return filterOptions.sortByOptions
|
||||
.map((o) => {
|
||||
return {
|
||||
message: intl.formatMessage({ id: o.messageID }),
|
||||
value: o.value,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.message.localeCompare(b.message))
|
||||
.map((option) => (
|
||||
<Dropdown.Item
|
||||
onSelect={onChangeSortBy}
|
||||
key={option.value}
|
||||
className="bg-secondary text-white"
|
||||
eventKey={option.value}
|
||||
>
|
||||
{option.message}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const currentSortBy = filterOptions.sortByOptions.find(
|
||||
(o) => o.value === filter.sortBy
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!withSidebar && (
|
||||
@@ -342,56 +404,21 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
>
|
||||
<FilterButton
|
||||
onClick={() => openFilterDialog()}
|
||||
filter={filter}
|
||||
count={filter.count()}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
? intl.formatMessage({ id: currentSortBy.messageID })
|
||||
: ""}
|
||||
</Dropdown.Toggle>
|
||||
</InputGroup.Prepend>
|
||||
<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
|
||||
? faCaretUp
|
||||
: faCaretDown
|
||||
}
|
||||
/>
|
||||
</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={faRandom} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
<SortBySelect
|
||||
className="mr-2"
|
||||
sortBy={filter.sortBy}
|
||||
sortDirection={filter.sortDirection}
|
||||
options={filterOptions.sortByOptions}
|
||||
onChangeSortBy={onChangeSortBy}
|
||||
onChangeSortDirection={onChangeSortDirection}
|
||||
onReshuffleRandomSort={onReshuffleRandomSort}
|
||||
/>
|
||||
|
||||
<PageSizeSelector
|
||||
pageSize={filter.itemsPerPage}
|
||||
|
||||
@@ -15,14 +15,17 @@ import {
|
||||
faPencilAlt,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import cx from "classnames";
|
||||
|
||||
export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
||||
children,
|
||||
}) => {
|
||||
export const OperationDropdown: React.FC<
|
||||
PropsWithChildren<{
|
||||
className?: string;
|
||||
}>
|
||||
> = ({ className, children }) => {
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown className={className} as={ButtonGroup}>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
@@ -33,6 +36,21 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const OperationDropdownItem: React.FC<{
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}> = ({ text, onClick, className }) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
className={cx("bg-secondary text-white", className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IListFilterOperation {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
|
||||
@@ -412,6 +412,12 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-tags .clear-all-button {
|
||||
color: $text-color;
|
||||
// to match filter pills
|
||||
@@ -929,25 +935,49 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
// make controls slightly larger on mobile
|
||||
@include media-breakpoint-down(xs) {
|
||||
.btn,
|
||||
.form-control {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-search-container {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.search-term-input {
|
||||
flex-grow: 1;
|
||||
margin-right: 0.25rem;
|
||||
margin-right: 0;
|
||||
|
||||
.clearable-text-field {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-filter-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
background-color: $body-bg;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
padding: 0.5rem;
|
||||
position: sticky;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
.sidebar .search-term-input {
|
||||
margin-right: 0.5rem;
|
||||
.sidebar .sidebar-search-container {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
|
||||
href: "/scenes",
|
||||
icon: faPlayCircle,
|
||||
hotkey: "g s",
|
||||
userCreatable: true,
|
||||
},
|
||||
{
|
||||
name: "images",
|
||||
|
||||
@@ -19,7 +19,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||
import { TaggerContext } from "../Tagger/context";
|
||||
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faPencil,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faTimes,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { SceneMergeModal } from "./SceneMergeDialog";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import TextUtils from "src/utils/text";
|
||||
@@ -27,8 +33,10 @@ import { View } from "../List/views";
|
||||
import { FileSize } from "../Shared/FileSize";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import { IListFilterOperation } from "../List/ListOperationButtons";
|
||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||
import {
|
||||
OperationDropdown,
|
||||
OperationDropdownItem,
|
||||
} from "../List/ListOperationButtons";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||
@@ -49,6 +57,11 @@ import {
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import { PatchContainerComponent } from "src/patch";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { FilterButton } from "../List/Filters/FilterButton";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { ListViewOptions } from "../List/ListViewOptions";
|
||||
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
@@ -82,33 +95,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
function usePlayScene() {
|
||||
const history = useHistory();
|
||||
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const cont = config?.interface.continuePlaylistDefault ?? false;
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
|
||||
const playScene = useCallback(
|
||||
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
|
||||
history.push(queue.makeLink(sceneID, options));
|
||||
(queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => {
|
||||
history.push(
|
||||
queue.makeLink(sceneID, { autoPlay, continue: cont, ...options })
|
||||
);
|
||||
},
|
||||
[history]
|
||||
[history, cont, autoPlay]
|
||||
);
|
||||
|
||||
return playScene;
|
||||
}
|
||||
|
||||
function usePlaySelected(selectedIds: Set<string>) {
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playSelected = useCallback(() => {
|
||||
// populate queue and go to first scene
|
||||
const sceneIDs = Array.from(selectedIds.values());
|
||||
const queue = SceneQueue.fromSceneIDList(sceneIDs);
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
playScene(queue, sceneIDs[0], { autoPlay });
|
||||
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
|
||||
|
||||
playScene(queue, sceneIDs[0]);
|
||||
}, [selectedIds, playScene]);
|
||||
|
||||
return playSelected;
|
||||
}
|
||||
|
||||
function usePlayFirst() {
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playFirst = useCallback(
|
||||
(queue: SceneQueue, sceneID: string, index: number) => {
|
||||
// populate queue and go to first scene
|
||||
playScene(queue, sceneID, { sceneIndex: index });
|
||||
},
|
||||
[playScene]
|
||||
);
|
||||
|
||||
return playFirst;
|
||||
}
|
||||
|
||||
function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playRandom = useCallback(async () => {
|
||||
@@ -130,15 +161,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||
if (scene) {
|
||||
// navigate to the image player page
|
||||
const queue = SceneQueue.fromListFilterModel(filterCopy);
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
|
||||
playScene(queue, scene.id, { sceneIndex: index });
|
||||
}
|
||||
}, [
|
||||
filter,
|
||||
count,
|
||||
config?.interface.autostartVideoOnPlaySelected,
|
||||
playScene,
|
||||
]);
|
||||
}, [filter, count, playScene]);
|
||||
|
||||
return playRandom;
|
||||
}
|
||||
@@ -213,12 +238,23 @@ const SidebarContent: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
|
||||
count?: number;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
view,
|
||||
showEditFilter,
|
||||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
onClose={onClose}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
@@ -262,10 +298,193 @@ const SidebarContent: React.FC<{
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</ScenesFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IOperations {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
isDisplayed?: () => boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ListToolbarContent: React.FC<{
|
||||
criteriaCount: number;
|
||||
items: GQL.SlimSceneDataFragment[];
|
||||
selectedIds: Set<string>;
|
||||
operations: IOperations[];
|
||||
onToggleSidebar: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onPlay: () => void;
|
||||
onCreateNew: () => void;
|
||||
}> = ({
|
||||
criteriaCount,
|
||||
items,
|
||||
selectedIds,
|
||||
operations,
|
||||
onToggleSidebar,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPlay,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasSelection && (
|
||||
<div>
|
||||
<FilterButton
|
||||
onClick={() => onToggleSidebar()}
|
||||
count={criteriaCount}
|
||||
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasSelection && (
|
||||
<div className="selected-items-info">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="minimal"
|
||||
onClick={() => onSelectNone()}
|
||||
title={intl.formatMessage({ id: "actions.select_none" })}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
<span>{selectedIds.size} selected</span>
|
||||
<Button variant="link" onClick={() => onSelectAll()}>
|
||||
<FormattedMessage id="actions.select_all" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<ButtonGroup>
|
||||
{!!items.length && (
|
||||
<Button
|
||||
className="play-button"
|
||||
variant="secondary"
|
||||
onClick={() => onPlay()}
|
||||
title={intl.formatMessage({ id: "actions.play" })}
|
||||
>
|
||||
<Icon icon={faPlay} />
|
||||
</Button>
|
||||
)}
|
||||
{!hasSelection && (
|
||||
<Button
|
||||
className="create-new-button"
|
||||
variant="secondary"
|
||||
onClick={() => onCreateNew()}
|
||||
title={intl.formatMessage(
|
||||
{ id: "actions.create_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "scene" }) }
|
||||
)}
|
||||
>
|
||||
<Icon icon={faPlus} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => onEdit()}>
|
||||
<Icon icon={faPencil} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="btn-danger-minimal"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Icon icon={faTrash} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<OperationDropdown className="scene-list-operations">
|
||||
{operations.map((o) => {
|
||||
if (o.isDisplayed && !o.isDisplayed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OperationDropdownItem
|
||||
key={o.text}
|
||||
onClick={o.onClick}
|
||||
text={o.text}
|
||||
className={o.className}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OperationDropdown>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ListResultsHeader: React.FC<{
|
||||
loading: boolean;
|
||||
filter: ListFilterModel;
|
||||
totalCount: number;
|
||||
metadataByline?: React.ReactNode;
|
||||
onChangeFilter: (filter: ListFilterModel) => void;
|
||||
}> = ({ loading, filter, totalCount, metadataByline, onChangeFilter }) => {
|
||||
return (
|
||||
<ButtonToolbar className="scene-list-header">
|
||||
<div>
|
||||
<PaginationIndex
|
||||
loading={loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SortBySelect
|
||||
options={filter.options.sortByOptions}
|
||||
sortBy={filter.sortBy}
|
||||
sortDirection={filter.sortDirection}
|
||||
onChangeSortBy={(s) =>
|
||||
onChangeFilter(filter.setSortBy(s ?? undefined))
|
||||
}
|
||||
onChangeSortDirection={() =>
|
||||
onChangeFilter(filter.toggleSortDirection())
|
||||
}
|
||||
onReshuffleRandomSort={() =>
|
||||
onChangeFilter(filter.reshuffleRandomSort())
|
||||
}
|
||||
/>
|
||||
<PageSizeSelector
|
||||
pageSize={filter.itemsPerPage}
|
||||
setPageSize={(s) => onChangeFilter(filter.setPageSize(s))}
|
||||
/>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
displayModeOptions={filter.options.displayModeOptions}
|
||||
onSetDisplayMode={(mode) =>
|
||||
onChangeFilter(filter.setDisplayMode(mode))
|
||||
}
|
||||
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
|
||||
/>
|
||||
</div>
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilteredScenes {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
defaultSort?: string;
|
||||
@@ -312,6 +531,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
hasSelection,
|
||||
} = listSelect;
|
||||
@@ -337,13 +557,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
});
|
||||
|
||||
const metadataByline = useMemo(() => {
|
||||
if (cachedResult.loading) return "";
|
||||
if (cachedResult.loading) return null;
|
||||
|
||||
return renderMetadataByline(cachedResult) ?? "";
|
||||
return renderMetadataByline(cachedResult) ?? null;
|
||||
}, [cachedResult]);
|
||||
|
||||
const playSelected = usePlaySelected(selectedIds);
|
||||
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
|
||||
|
||||
const playRandom = usePlayRandom(filter, totalCount);
|
||||
const playSelected = usePlaySelected(selectedIds);
|
||||
const playFirst = usePlayFirst();
|
||||
|
||||
function onCreateNew() {
|
||||
history.push("/scenes/new");
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if there are selected items, play those
|
||||
if (hasSelection) {
|
||||
playSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise, play the first item in the list
|
||||
const sceneID = items[0].id;
|
||||
playFirst(queue, sceneID, 0);
|
||||
}
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
@@ -381,16 +624,41 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
);
|
||||
}
|
||||
|
||||
const otherOperations: IListFilterOperation[] = [
|
||||
function onEdit() {
|
||||
showModal(
|
||||
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
|
||||
);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const otherOperations = [
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_selected" }),
|
||||
onClick: playSelected,
|
||||
isDisplayed: () => hasSelection,
|
||||
icon: faPlay,
|
||||
text: intl.formatMessage({ id: "actions.play" }),
|
||||
onClick: () => onPlay(),
|
||||
isDisplayed: () => items.length > 0,
|
||||
className: "play-item",
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
{ id: "actions.create_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "scene" }) }
|
||||
),
|
||||
onClick: () => onCreateNew(),
|
||||
isDisplayed: () => !hasSelection,
|
||||
className: "create-new-item",
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||
onClick: playRandom,
|
||||
isDisplayed: () => totalCount > 1,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||
@@ -452,34 +720,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
/>
|
||||
</Sidebar>
|
||||
<div>
|
||||
<FilteredListToolbar
|
||||
<ButtonToolbar
|
||||
className={cx("scene-list-toolbar", {
|
||||
"has-selection": hasSelection,
|
||||
})}
|
||||
>
|
||||
<ListToolbarContent
|
||||
criteriaCount={filter.count()}
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onCreateNew={onCreateNew}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
</ButtonToolbar>
|
||||
|
||||
<ListResultsHeader
|
||||
loading={cachedResult.loading}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||
zoomable
|
||||
totalCount={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
@@ -489,14 +759,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
|
||||
@@ -1003,3 +1003,92 @@ input[type="range"].blue-slider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list-toolbar,
|
||||
.scene-list-header {
|
||||
align-items: center;
|
||||
background-color: $body-bg;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list-toolbar {
|
||||
flex-wrap: wrap;
|
||||
// offset the main padding
|
||||
margin-top: -0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
position: sticky;
|
||||
top: $navbar-height;
|
||||
z-index: 10;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.selected-items-info .btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
// hide drop down menu items for play and create new
|
||||
// when the buttons are visible
|
||||
@include media-breakpoint-up(sm) {
|
||||
.scene-list-operations {
|
||||
.play-item,
|
||||
.create-new-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide play and create new buttons on xs screens
|
||||
// show these in the drop down menu instead
|
||||
@include media-breakpoint-down(xs) {
|
||||
.play-button,
|
||||
.create-new-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list-header {
|
||||
flex-wrap: wrap-reverse;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.paginationIndex {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// center the header on smaller screens
|
||||
@include media-breakpoint-down(sm) {
|
||||
& > div,
|
||||
& > div:last-child {
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body .scene-list-toolbar {
|
||||
top: calc($sticky-detail-header-height + $navbar-height);
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,8 +774,9 @@ $sidebar-width: 250px;
|
||||
.sidebar {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-top: 4rem;
|
||||
margin-top: $navbar-height;
|
||||
overflow-y: auto;
|
||||
padding-top: 0.5rem;
|
||||
position: fixed;
|
||||
scrollbar-gutter: stable;
|
||||
top: 0;
|
||||
@@ -890,8 +891,7 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||
padding-left: 0;
|
||||
position: sticky;
|
||||
|
||||
// sticky detail header is 50px + 3.3rem
|
||||
top: calc(50px + 3.3rem);
|
||||
top: calc($sticky-detail-header-height + $navbar-height);
|
||||
|
||||
.sidebar-toolbar {
|
||||
padding-top: 15px;
|
||||
@@ -918,7 +918,6 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||
flex: 100% 0 0;
|
||||
height: calc(100vh - 4rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
padding-top: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
Stash supports captioning with SRT and VTT files.
|
||||
|
||||
These files need to be named as follows:
|
||||
Captions will only be detected if they are located in the same folder as the corresponding scene file.
|
||||
|
||||
Ensure the caption files follow these naming conventions:
|
||||
|
||||
## Scene
|
||||
|
||||
- {scene_name}.{language_code}.ext
|
||||
- {scene_name}.ext
|
||||
- {scene_file_name}.{language_code}.ext
|
||||
- {scene_file_name}.ext
|
||||
|
||||
Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.
|
||||
|
||||
Scenes with captions can be filtered with the `captions` criterion.
|
||||
|
||||
**Note:** If the caption file was added after the scene was initially added during scan you will need to run a Selective Scan task for it to show up.
|
||||
**Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Stash supports plugins that can do the following:
|
||||
|
||||
- perform custom tasks when triggered by the user from the Tasks page
|
||||
- perform custom tasks when triggered from specific events
|
||||
- add custom CSS to the UI
|
||||
@@ -14,7 +15,7 @@ Plugin tasks can be implemented using embedded Javascript, or by calling an exte
|
||||
|
||||
Plugins can be installed and managed from the `Settings > Plugins` page.
|
||||
|
||||
Scrapers are installed using the `Available Plugins` section. This section allows configuring sources from which to install plugins. The `Community (stable)` source is configured by default. This source contains plugins for the current _stable_ version of stash.
|
||||
Plugins are installed using the `Available Plugins` section. This section allows configuring sources from which to install plugins. The `Community (stable)` source is configured by default. This source contains plugins for the current _stable_ version of stash.
|
||||
|
||||
These are the plugin sources maintained by the stashapp organisation:
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// variables required by other scss files
|
||||
|
||||
// this is calculated from the existing height
|
||||
// TODO: we should set this explicitly in the navbar
|
||||
$navbar-height: 48.75px;
|
||||
|
||||
$sticky-detail-header-height: 50px;
|
||||
|
||||
@import "styles/theme";
|
||||
@import "styles/range";
|
||||
@import "styles/scrollbars";
|
||||
@@ -55,7 +56,7 @@ body {
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
@media (orientation: portrait) {
|
||||
padding: 1rem 0 $navbar-height;
|
||||
padding: 0.5rem 0 $navbar-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,10 +86,10 @@ dd {
|
||||
|
||||
.sticky.detail-header {
|
||||
display: block;
|
||||
min-height: 50px;
|
||||
min-height: $sticky-detail-header-height;
|
||||
padding: unset;
|
||||
position: fixed;
|
||||
top: 3.3rem;
|
||||
top: $navbar-height;
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
@@ -692,8 +693,7 @@ div.dropdown-menu {
|
||||
|
||||
.badge {
|
||||
margin: unset;
|
||||
// stylelint-disable declaration-no-important
|
||||
white-space: normal !important;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,9 @@ div.dropdown-menu {
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-up(xl) {
|
||||
height: $navbar-height;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding: 0.5em 0;
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"open_random": "Open Random",
|
||||
"optimise_database": "Optimise Database",
|
||||
"overwrite": "Overwrite",
|
||||
"play": "Play",
|
||||
"play_random": "Play Random",
|
||||
"play_selected": "Play selected",
|
||||
"preview": "Preview",
|
||||
@@ -124,9 +125,12 @@
|
||||
"set_image": "Set image…",
|
||||
"show": "Show",
|
||||
"show_configuration": "Show Configuration",
|
||||
"show_results": "Show results",
|
||||
"show_count_results": "Show {count} results",
|
||||
"sidebar": {
|
||||
"close": "Close sidebar",
|
||||
"open": "Open sidebar"
|
||||
"open": "Open sidebar",
|
||||
"toggle": "Toggle sidebar"
|
||||
},
|
||||
"skip": "Skip",
|
||||
"split": "Split",
|
||||
|
||||
@@ -3,11 +3,11 @@ import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import { getCountryByISO } from "src/utils/country";
|
||||
import { StringCriterion, StringCriterionOption } from "./criterion";
|
||||
|
||||
export const CountryCriterionOption = new StringCriterionOption(
|
||||
"country",
|
||||
"country",
|
||||
() => new CountryCriterion()
|
||||
);
|
||||
export const CountryCriterionOption = new StringCriterionOption({
|
||||
messageID: "country",
|
||||
type: "country",
|
||||
makeCriterion: () => new CountryCriterion(),
|
||||
});
|
||||
|
||||
export class CountryCriterion extends StringCriterion {
|
||||
constructor() {
|
||||
|
||||
@@ -526,13 +526,12 @@ export class IHierarchicalLabeledIdCriterion extends ModifierCriterion<IHierarch
|
||||
|
||||
export class StringCriterionOption extends ModifierCriterionOption {
|
||||
constructor(
|
||||
messageID: string,
|
||||
value: CriterionType,
|
||||
makeCriterion?: () => ModifierCriterion<CriterionValue>
|
||||
options: Partial<
|
||||
Omit<IModifierCriterionOptionParams, "messageID" | "type">
|
||||
> &
|
||||
Pick<IModifierCriterionOptionParams, "messageID" | "type">
|
||||
) {
|
||||
super({
|
||||
messageID,
|
||||
type: value,
|
||||
modifierOptions: [
|
||||
CriterionModifier.Equals,
|
||||
CriterionModifier.NotEquals,
|
||||
@@ -545,9 +544,8 @@ export class StringCriterionOption extends ModifierCriterionOption {
|
||||
],
|
||||
defaultModifier: CriterionModifier.Equals,
|
||||
inputType: "text",
|
||||
makeCriterion: makeCriterion
|
||||
? makeCriterion
|
||||
: () => new StringCriterion(this),
|
||||
makeCriterion: () => new StringCriterion(this),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -556,7 +554,7 @@ export function createStringCriterionOption(
|
||||
type: CriterionType,
|
||||
messageID?: string
|
||||
) {
|
||||
return new StringCriterionOption(messageID ?? type, type);
|
||||
return new StringCriterionOption({ messageID: messageID ?? type, type });
|
||||
}
|
||||
|
||||
export class MandatoryStringCriterionOption extends ModifierCriterionOption {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
import { StringCriterion, StringCriterionOption } from "./criterion";
|
||||
|
||||
export const PathCriterionOption = new StringCriterionOption(
|
||||
"path",
|
||||
"path",
|
||||
() => new PathCriterion()
|
||||
);
|
||||
export const PathCriterionOption = new StringCriterionOption({
|
||||
messageID: "path",
|
||||
type: "path",
|
||||
defaultModifier: CriterionModifier.Includes,
|
||||
makeCriterion: () => new PathCriterion(),
|
||||
});
|
||||
|
||||
export class PathCriterion extends StringCriterion {
|
||||
constructor() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CriterionOption } from "./criteria/criterion";
|
||||
import { DisplayMode } from "./types";
|
||||
|
||||
interface ISortByOption {
|
||||
export interface ISortByOption {
|
||||
messageID: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -521,6 +521,34 @@ export class ListFilterModel {
|
||||
public setPageSize(pageSize: number) {
|
||||
const ret = this.clone();
|
||||
ret.itemsPerPage = pageSize;
|
||||
ret.currentPage = 1; // reset to first page
|
||||
return ret;
|
||||
}
|
||||
|
||||
public setSortBy(sortBy: string | undefined) {
|
||||
const ret = this.clone();
|
||||
ret.sortBy = sortBy;
|
||||
ret.currentPage = 1; // reset to first page
|
||||
return ret;
|
||||
}
|
||||
|
||||
public toggleSortDirection() {
|
||||
const ret = this.clone();
|
||||
|
||||
if (ret.sortDirection === SortDirectionEnum.Asc) {
|
||||
ret.sortDirection = SortDirectionEnum.Desc;
|
||||
} else {
|
||||
ret.sortDirection = SortDirectionEnum.Asc;
|
||||
}
|
||||
|
||||
ret.currentPage = 1; // reset to first page
|
||||
return ret;
|
||||
}
|
||||
|
||||
public reshuffleRandomSort() {
|
||||
const ret = this.clone();
|
||||
ret.currentPage = 1;
|
||||
ret.randomSeed = -1;
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user