Compare commits

...

13 Commits

Author SHA1 Message Date
DogmaDragon
8a1884eb32 Update capitalization for sprite generation heading 2026-02-28 16:15:31 +02:00
Gykes
c874bd560e Fix: Custom Field Filtering (#6614)
* add tests
* Refactor queryBuilder: split args into per-clause fields
2026-02-28 11:05:13 +11:00
WithoutPants
c7e1c3da69 Fix panic when library path has trailing path separator (#6619)
* Replace panic with warning if creating a folder hierarchy where parent is equal to current
* Clean stash paths so that comparison works correctly when creating folder hierarchies
2026-02-28 10:51:02 +11:00
Gykes
3b8f6bd94c update logs and fix UNIQUE constraint failure (#6617) 2026-02-28 09:11:13 +11:00
WithoutPants
d8448ba37e Add basename and parent_folders fields to Folder graphql interface (#6494)
* Add basename field to folder
* Add parent_folders field to folder
* Add basename column to folder table
* Add basename filter field
* Create missing folder hierarchies during migration
* Treat files/folders in zips where path can't be made relative as not found

Addresses an issue during clean where corrupt folder entries in zip files could not be removed due to an error during the call to Rel.
2026-02-27 10:58:11 +11:00
WithoutPants
ead0c7fe07 Add sidebar to Tag list (#6610)
* Fix image export dialog
* Add sidebar to TagList
* Update plugin docs and types
* Remove ItemList as it is no longer referenced
2026-02-27 07:44:23 +11:00
WithoutPants
660feabced Update minimatch and ajv dependencies (#6609)
* Update minimatch
* Update ajv
2026-02-27 07:43:16 +11:00
WithoutPants
e52ac14d56 Fix missing folder corruption during scanning (#6608)
* Add root paths parameter to GetOrCreateFolderHierarchy

Ensures that folders are only created up to the root library paths.

* Create full folder hierarchy when scanning a new folder

During a recursive scan, folders should be created as they are encountered (folders are handled in a single thread). This change applies only during a selective scan. Creates up to the root library folder.

* Create folder hierarchy on new file scan

This should only apply when scanning a specific file, as parent folders should be been created during a recursive scan.

* Fix existing folders with missing parents during scan
2026-02-27 07:42:53 +11:00
Gykes
b77abd64e2 FR: Add Missing is-missing Filter Options Across all Object Types (#6565)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-02-26 16:36:54 +11:00
WithoutPants
ed58d18334 Add sidebar to images list (#6607)
* Use effective filter for keybinds/view random
* Refactor ImageList to use sidebar
* Add performer age filter to gallery sidebar
* Port metadata info changes
* Fix incorrect patch component parameter
* Update plugin doc and types
2026-02-26 14:13:15 +11:00
WithoutPants
c522e54805 Show unsupported filter criteria in filter tags (#6604)
* Show unsupported filter criteria in filter tags

Shows a warning coloured filter tag, with warning icon and text "<type> (unsupported) ...". Cannot be edited, can only be removed. Won't be saved to saved filters.

* Generalise filtered recommendation rows. Include warning popover for unsupported criteria
2026-02-26 07:55:26 +11:00
WithoutPants
5734ee43ff Add sidebar to scene markers list (#6603)
* Add tag markers filter
* Add marker count and markers filter to performer filter
* Add sidebar to marker list
2026-02-26 07:54:40 +11:00
DogmaDragon
c9f0dba62f Fix capitalization in custom localisation heading [skip-ci] (#6606) 2026-02-26 07:54:12 +11:00
81 changed files with 3362 additions and 1541 deletions

View File

@@ -6,11 +6,14 @@ type Fingerprint {
type Folder {
id: ID!
path: String!
basename: String!
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder
"Returns all parent folders in order from immediate parent to top-level"
parent_folders: [Folder!]!
zip_file: BasicFile
mod_time: Time!

View File

@@ -177,6 +177,8 @@ input PerformerFilterType {
tag_count: IntCriterionInput
"Filter by scene count"
scene_count: IntCriterionInput
"Filter by marker count (via scene)"
marker_count: IntCriterionInput
"Filter by image count"
image_count: IntCriterionInput
"Filter by gallery count"
@@ -220,6 +222,8 @@ input PerformerFilterType {
galleries_filter: GalleryFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related scene markers (via scene) that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
@@ -684,6 +688,8 @@ input TagFilterType {
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related scene markers that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
@@ -816,6 +822,7 @@ input FolderFilterType {
NOT: FolderFilterType
path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput

View File

@@ -11,6 +11,7 @@
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden FolderParentFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
@@ -65,12 +66,16 @@ type Loaders struct {
StudioByID *StudioLoader
StudioCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderParentFolderIDsLoader
}
type Middleware struct {
@@ -161,6 +166,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchFolders(ctx),
},
FolderParentFolderIDs: &FolderParentFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersParentFolderIDs(ctx),
},
SceneFiles: &SceneFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
@@ -406,6 +416,17 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI
}
}
func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
return func(keys []int) (ret [][]models.FileID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -0,0 +1,225 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
type FolderParentFolderIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderParentFolderIDsLoader {
return &FolderParentFolderIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FolderParentFolderIDsLoader batches and caches requests
type FolderParentFolderIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[models.FolderID][]models.FolderID
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *folderParentFolderIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type folderParentFolderIDsLoaderBatch struct {
keys []models.FolderID
data [][]models.FolderID
error []error
closing bool
done chan struct{}
}
// Load a FolderID by key, batching and caching will be applied automatically
func (l *FolderParentFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a FolderID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderParentFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]models.FolderID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]models.FolderID, error) {
<-batch.done
var data []models.FolderID
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *FolderParentFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, errors
}
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderParentFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]models.FolderID, []error) {
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *FolderParentFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := make([]models.FolderID, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *FolderParentFolderIDsLoader) Clear(key models.FolderID) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FolderParentFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
if l.cache == nil {
l.cache = map[models.FolderID][]models.FolderID{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderParentFolderIDsLoader, key models.FolderID) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderParentFolderIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *folderParentFolderIDsLoaderBatch) end(l *FolderParentFolderIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -2,11 +2,16 @@ package api
import (
"context"
"path/filepath"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models"
)
func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) {
return filepath.Base(obj.Path), nil
}
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
if obj.ParentFolderID == nil {
return nil, nil
@@ -15,6 +20,17 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
}
func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
@@ -19,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
if err := r.withTxn(ctx, func(ctx context.Context) error {
fileStore := r.repository.File
folderStore := r.repository.Folder
mover := file.NewMover(fileStore, folderStore)
mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())
mover.RegisterHooks(ctx)
var (
@@ -57,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
folderPath := *input.DestinationFolder
// ensure folder path is within the library
if err := r.validateFolderPath(folderPath); err != nil {
stashPaths := manager.GetInstance().Config.GetStashPaths()
if err := r.validateFolderPath(stashPaths, folderPath); err != nil {
return err
}
// get or create folder hierarchy
var err error
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath)
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -112,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
return true, nil
}
func (r *mutationResolver) validateFolderPath(folderPath string) error {
paths := manager.GetInstance().Config.GetStashPaths()
func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {
if l := paths.GetStashFromDirPath(folderPath); l == nil {
return fmt.Errorf("folder path %s must be within a stash library path", folderPath)
}

View File

@@ -38,3 +38,12 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
}
return nil
}
func (s StashConfigs) Paths() []string {
paths := make([]string, len(s))
for i, c := range s {
// #6618 - clean the path to ensure comparison works correctly
paths[i] = filepath.Clean(c.Path)
}
return paths
}

View File

@@ -123,7 +123,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
ZipFileExtensions: cfg.GetGalleryExtensions(),
// ScanFilters is set in ScanJob.Execute
// HandlerRequiredFilters is set in ScanJob.Execute
Rescan: input.Rescan,
RootPaths: cfg.GetStashPaths().Paths(),
Rescan: input.Rescan,
}
scanJob := ScanJob{

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
@@ -12,8 +13,9 @@ import (
)
// GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found.
// Does not create any folders in the file system
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths.
// Does not create any folders in the file system.
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) {
// get or create folder hierarchy
// assume case sensitive when searching for the folder
const caseSensitive = true
@@ -23,17 +25,33 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
}
if folder == nil {
parentPath := filepath.Dir(path)
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath)
if err != nil {
return nil, err
var parentID *models.FolderID
if !slices.Contains(rootPaths, path) {
parentPath := filepath.Dir(path)
// safety check - don't allow parent path to be the same as the current path,
// otherwise we could end up in an infinite loop
if parentPath == path {
// #6618 - log a warning and return nil for the parent ID,
// which will cause the folder to be created with no parent
logger.Warnf("parent path is the same as the current path: %s", path)
return nil, nil
}
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths)
if err != nil {
return nil, err
}
parentID = &parent.ID
}
now := time.Now()
folder = &models.Folder{
Path: path,
ParentFolderID: &parent.ID,
ParentFolderID: parentID,
DirEntry: models.DirEntry{
// leave mod time empty for now - it will be updated when the folder is scanned
},
@@ -41,6 +59,8 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
UpdatedAt: now,
}
logger.Infof("%s doesn't exist. Creating new folder entry...", path)
if err = fc.Create(ctx, folder); err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@@ -49,12 +69,18 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
return folder, nil
}
func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error {
if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil {
type zipHierarchyMover struct {
folderStore models.FolderReaderWriter
files models.FileFinderUpdater
rootPaths []string
}
func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err)
}
if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil {
if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err)
}
@@ -63,8 +89,8 @@ func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWr
// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes
// ZipFileID from folders under oldPath.
func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID)
func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID)
if err != nil {
return err
}
@@ -83,7 +109,7 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
}
newZfPath := filepath.Join(newPath, relZfPath)
newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath)
newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths)
if err != nil {
return err
}
@@ -91,14 +117,14 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
// add ZipFileID to new folder
logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path)
newFolder.ZipFileID = &zipFileID
if err = folderStore.Update(ctx, newFolder); err != nil {
if err = m.folderStore.Update(ctx, newFolder); err != nil {
return err
}
// remove ZipFileID from old folder
logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path)
oldFolder.ZipFileID = nil
if err = folderStore.Update(ctx, oldFolder); err != nil {
if err = m.folderStore.Update(ctx, oldFolder); err != nil {
return err
}
}
@@ -106,9 +132,9 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
return nil
}
func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error {
func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error {
// move contained files if file is a zip file
zipFiles, err := files.FindByZipFileID(ctx, zipFileID)
zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID)
if err != nil {
return fmt.Errorf("finding contained files in file %s: %w", oldPath, err)
}
@@ -129,7 +155,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
newZfDir := filepath.Join(newPath, relZfDir)
// folder should have been created by transferZipFolderHierarchy
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir)
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths)
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -137,7 +163,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
// update file parent folder
zfBase.ParentFolderID = newZfFolder.ID
logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path)
if err := files.Update(ctx, zf); err != nil {
if err := m.files.Update(ctx, zf); err != nil {
return fmt.Errorf("updating file %s: %w", oldZfPath, err)
}
}

View File

@@ -45,9 +45,12 @@ type Mover struct {
moved map[string]string
foldersCreated []string
// needed for creating folder hierarchy when moving zip file entries
rootPaths []string
}
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover {
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover {
return &Mover{
Files: fileStore,
Folders: folderStore,
@@ -55,6 +58,7 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade
renamerRemoverImpl: newRenamerRemoverImpl(),
mkDirFn: os.Mkdir,
},
rootPaths: rootPaths,
}
}
@@ -87,7 +91,13 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder,
return fmt.Errorf("file %s already exists", newPath)
}
if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil {
zipMover := zipHierarchyMover{
folderStore: m.Folders,
files: m.Files,
rootPaths: m.rootPaths,
}
if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -60,6 +61,10 @@ type Scanner struct {
// handlers are called after a file has been scanned.
FileHandlers []Handler
// RootPaths form the top-level paths for the library.
// Used to determine the root of the folder hierarchy when creating folders.
RootPaths []string
// Rescan indicates whether files should be rescanned even if they haven't changed.
Rescan bool
@@ -193,6 +198,10 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol
return f, err
}
func (s *Scanner) isRootPath(path string) bool {
return path == "." || slices.Contains(s.RootPaths, path)
}
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
renamed, err := s.handleFolderRename(ctx, file)
if err != nil {
@@ -212,18 +221,16 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo
UpdatedAt: now,
}
dir := filepath.Dir(file.Path)
if dir != "." {
parentFolderID, err := s.getFolderID(ctx, dir)
if !s.isRootPath(file.Path) {
dir := filepath.Dir(file.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths)
if err != nil {
return nil, fmt.Errorf("getting parent folder %q: %w", dir, err)
}
// if parent folder doesn't exist, assume it's a top-level folder
// this may not be true if we're using multiple goroutines
if parentFolderID != nil {
toCreate.ParentFolderID = parentFolderID
}
toCreate.ParentFolderID = &parentFolder.ID
}
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
@@ -312,6 +319,19 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
}
}
// handle case where parent folder was not previously set
if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) {
logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths)
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
existing.ParentFolderID = &parentFolder.ID
update = true
}
if update {
var err error
if err = s.Repository.Folder.Update(ctx, existing); err != nil {
@@ -393,13 +413,31 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
baseFile.UpdatedAt = now
// find the parent folder
parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path))
folderPath := filepath.Dir(path)
parentFolderID, err := s.getFolderID(ctx, folderPath)
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", path, err)
}
if parentFolderID == nil {
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
// parent folders should have been created before scanning this file in a recursive scan
// assume that we are scanning specifically and only this file,
// so we should create the parent folder hierarchy if it doesn't exist
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths)
if err != nil {
return fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
parentFolderID = &parentFolder.ID
return nil
}); err != nil {
return nil, err
}
}
if parentFolderID == nil {
// shouldn't happen
return nil, fmt.Errorf("parent folder ID is nil for %q", path)
}
baseFile.ParentFolderID = *parentFolderID
@@ -604,13 +642,19 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
fBaseCopy.Fingerprints = updatedBase.Fingerprints
*updatedBase = fBaseCopy
zipMover := zipHierarchyMover{
folderStore: s.Repository.Folder,
files: s.Repository.File,
rootPaths: s.RootPaths,
}
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
if err := s.Repository.File.Update(ctx, updated); err != nil {
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
}
if s.IsZipFile(updatedBase.Basename) {
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
}
}

View File

@@ -99,7 +99,9 @@ func (f *zipFS) rel(name string) (string, error) {
relName, err := filepath.Rel(f.zipPath, name)
if err != nil {
return "", fmt.Errorf("internal error getting relative path: %w", err)
// if the path is not relative to the zip path, then it's not found in the zip file,
// so treat this as a file not found
return "", fs.ErrNotExist
}
// convert relName to use slash, since zip files do so regardless

View File

@@ -18,10 +18,8 @@ type FolderQueryOptions struct {
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"`
Path *StringCriterionInput `json:"path,omitempty"`
Basename *StringCriterionInput `json:"basename,omitempty"`
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
// Filter by modification time

View File

@@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
return r0, r1
}
// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs
func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
ret := _m.Called(ctx, folderIDs)
var r0 [][]models.FolderID
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
r0 = rf(ctx, folderIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]models.FolderID)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
r1 = rf(ctx, folderIDs)
} else {
r1 = ret.Error(1)
}
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)

View File

@@ -158,6 +158,8 @@ type PerformerFilterType struct {
TagCount *IntCriterionInput `json:"tag_count"`
// Filter by scene count
SceneCount *IntCriterionInput `json:"scene_count"`
// Filter by scene marker count (via scene)
MarkerCount *IntCriterionInput `json:"marker_count"`
// Filter by image count
ImageCount *IntCriterionInput `json:"image_count"`
// Filter by gallery count
@@ -202,6 +204,8 @@ type PerformerFilterType struct {
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
// Filter by related tags that meet this criteria
TagsFilter *TagFilterType `json:"tags_filter"`
// Filter by related scene markers (via scene) that meet this criteria
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at

View File

@@ -15,6 +15,7 @@ type FolderFinder interface {
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
}
type FolderQueryer interface {

View File

@@ -56,6 +56,8 @@ type TagFilterType struct {
PerformersFilter *PerformerFilterType `json:"performers_filter"`
// Filter by related studios that meet this criteria
StudiosFilter *StudioFilterType `json:"studios_filter"`
// Filter by related scene markers that meet this criteria
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at

View File

@@ -1089,11 +1089,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder)
}
type relatedFilterHandler struct {
relatedIDCol string
relatedRepo repository
// column on the primary table that relates to the related table (eg scene_id)
relatedIDCol string
// repository for the related table (eg sceneRepository)
relatedRepo repository
// handler for the filter on the related table
relatedHandler criterionHandler
joinFn func(f *filterBuilder)
directJoin bool
// optional function to perform the necessary join(s) to the related table
joinFn func(f *filterBuilder)
// if true, related filter handler will be run using the existing filterBuilder instead of a subquery.
directJoin bool
}
func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
@@ -1124,7 +1129,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
return
}
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...)
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...)
}
type phashDistanceCriterionHandler struct {

View File

@@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 83
var appSchemaVersion uint = 84
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@@ -975,7 +975,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}

View File

@@ -20,6 +20,7 @@ const folderIDColumn = "folder_id"
type folderRow struct {
ID models.FolderID `db:"id" goqu:"skipinsert"`
Basename string `db:"basename"`
Path string `db:"path"`
ZipFileID null.Int `db:"zip_file_id"`
ParentFolderID null.Int `db:"parent_folder_id"`
@@ -30,6 +31,8 @@ type folderRow struct {
func (r *folderRow) fromFolder(o models.Folder) {
r.ID = o.ID
// derive basename from path
r.Basename = filepath.Base(o.Path)
r.Path = o.Path
r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)
r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID)
@@ -322,6 +325,90 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID
return ret, nil
}
func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
table := qb.table()
// SQL recursive query to get all parent folder IDs for each folder ID
/*
WITH RECURSIVE parent_folders AS (
SELECT id, parent_folder_id
FROM folders
WHERE id IN (folderIDs)
UNION ALL
SELECT f.id, f.parent_folder_id
FROM folders f
INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id
)
SELECT id, parent_folder_id FROM parent_folders;
*/
const parentFolders = "parent_folders"
const parentFolderID = "parent_folder_id"
const parentID = "parent_id"
const foldersAlias = "f"
const parentFoldersAlias = "pf"
foldersAliasedI := table.As(foldersAlias)
parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias)
q := dialect.From(parentFolders).Prepared(true).
WithRecursive(parentFolders,
dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)).
Where(table.Col(idColumn).In(folderIDs)).
Union(
dialect.From(foldersAliasedI).InnerJoin(
parentFoldersI,
goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))),
).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)),
),
).Select(idColumn, parentID)
type resultRow struct {
FolderID models.FolderID `db:"id"`
ParentFolderID null.Int `db:"parent_id"`
}
folderMap := make(map[models.FolderID]models.FolderID)
if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error {
var row resultRow
if err := r.StructScan(&row); err != nil {
return err
}
if row.ParentFolderID.Valid {
folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64)
} else {
folderMap[row.FolderID] = 0
}
return nil
}); err != nil {
return nil, err
}
ret := make([][]models.FolderID, len(folderIDs))
for i, folderID := range folderIDs {
var parents []models.FolderID
currentID := folderID
for {
parentID, exists := folderMap[currentID]
if !exists || parentID == 0 {
break
}
parents = append(parents, parentID)
currentID = parentID
}
ret[i] = parents
}
return ret, nil
}
func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {
table := qb.table()
@@ -513,7 +600,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}

View File

@@ -65,6 +65,7 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler {
folderFilter := qb.folderFilter
return compoundHandler{
stringCriterionHandler(folderFilter.Path, qb.table.Col("path")),
stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")),
&timestampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil},
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),

View File

@@ -33,6 +33,17 @@ func TestFolderQuery(t *testing.T) {
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
excludeIdxs: []int{folderIdxInZip},
},
{
name: "basename",
filter: &models.FolderFilterType{
Basename: &models.StringCriterionInput{
Value: getFolderBasename(folderIdxWithParentFolder, nil),
Modifier: models.CriterionModifierIncludes,
},
},
includeIdxs: []int{folderIdxWithParentFolder},
excludeIdxs: []int{folderIdxInZip},
},
{
name: "parent folder",
filter: &models.FolderFilterType{

View File

@@ -186,8 +186,6 @@ func Test_FolderStore_Update(t *testing.T) {
}
assert.Equal(copy, *s)
return
})
}
}
@@ -239,3 +237,75 @@ func Test_FolderStore_FindByPath(t *testing.T) {
})
}
}
func Test_FolderStore_GetManyParentFolderIDs(t *testing.T) {
var empty []models.FolderID
emptyResult := [][]models.FolderID{empty}
tests := []struct {
name string
parentFolderIDs []models.FolderID
want [][]models.FolderID
wantErr bool
}{
{
"valid with parent folders",
[]models.FolderID{folderIDs[folderIdxWithParentFolder]},
[][]models.FolderID{
{
folderIDs[folderIdxWithSubFolder],
folderIDs[folderIdxRoot],
},
},
false,
},
{
"valid multiple folders",
[]models.FolderID{
folderIDs[folderIdxWithParentFolder],
folderIDs[folderIdxWithSceneFiles],
},
[][]models.FolderID{
{
folderIDs[folderIdxWithSubFolder],
folderIDs[folderIdxRoot],
},
{
folderIDs[folderIdxForObjectFiles],
folderIDs[folderIdxRoot],
},
},
false,
},
{
"valid without parent folders",
[]models.FolderID{folderIDs[folderIdxRoot]},
emptyResult,
false,
},
{
"invalid folder id",
[]models.FolderID{invalidFolderID},
emptyResult,
// does not error, just returns empty result
false,
},
}
qb := db.Folder
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs)
if (err != nil) != tt.wantErr {
assert.Errorf(err, "FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
assert.Equal(got, tt.want)
})
}
}

View File

@@ -308,7 +308,16 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite
case "tags":
galleryRepository.tags.join(f, "tags_join", "galleries.id")
f.addWhere("tags_join.gallery_id IS NULL")
case "cover":
f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1")
f.addWhere("cover_join.image_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"title", "code", "rating", "details", "photographer",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')")
}
}

View File

@@ -119,7 +119,25 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri
case "scenes":
f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id")
f.addWhere("groups_scenes.scene_id IS NULL")
case "url":
groupsURLsTableMgr.join(f, "", "groups.id")
f.addWhere("group_urls.url IS NULL")
case "studio":
f.addWhere("groups.studio_id IS NULL")
case "performers":
f.addLeftJoin("groups_scenes", "gs_perf", "groups.id = gs_perf.group_id")
f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id")
f.addWhere("ps_perf.performer_id IS NULL")
case "tags":
groupRepository.tags.join(f, "tags_join", "groups.id")
f.addWhere("tags_join.group_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"aliases", "description", "director", "date", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')")
}
}

View File

@@ -926,7 +926,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
Megapixels null.Float
Size null.Float
}{}
if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}

View File

@@ -171,6 +171,9 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
return func(ctx context.Context, f *filterBuilder) {
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "url":
imagesURLsTableMgr.join(f, "", "images.id")
f.addWhere("image_urls.url IS NULL")
case "studio":
f.addWhere("images.studio_id IS NULL")
case "performers":
@@ -183,6 +186,12 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
imageRepository.tags.join(f, "tags_join", "images.id")
f.addWhere("tags_join.image_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"title", "details", "photographer", "date", "code", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')")
}
}

View File

@@ -0,0 +1,50 @@
-- we cannot add basename column directly because we require it to be NOT NULL
-- recreate folders table with basename column
PRAGMA foreign_keys=OFF;
CREATE TABLE `folders_new` (
`id` integer not null primary key autoincrement,
`basename` varchar(255) NOT NULL,
`path` varchar(255) NOT NULL,
`parent_folder_id` integer,
`zip_file_id` integer REFERENCES `files`(`id`),
`mod_time` datetime not null,
`created_at` datetime not null,
`updated_at` datetime not null,
foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL
);
-- copy data from old table to new table, setting basename to path temporarily
INSERT INTO `folders_new` (
`id`,
`basename`,
`path`,
`parent_folder_id`,
`zip_file_id`,
`mod_time`,
`created_at`,
`updated_at`
) SELECT
`id`,
`path`,
`path`,
`parent_folder_id`,
`zip_file_id`,
`mod_time`,
`created_at`,
`updated_at`
FROM `folders`;
DROP INDEX IF EXISTS `index_folders_on_parent_folder_id`;
DROP INDEX IF EXISTS `index_folders_on_path_unique`;
DROP INDEX IF EXISTS `index_folders_on_zip_file_id`;
DROP TABLE `folders`;
ALTER TABLE `folders_new` RENAME TO `folders`;
CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`);
CREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`);
CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL;
CREATE INDEX `index_folders_on_basename` on `folders` (`basename`);
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,385 @@
package migrations
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"slices"
"time"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
"gopkg.in/guregu/null.v4"
)
func post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
db: db,
},
folderCache: make(map[string]folderInfo),
}
rootPaths := config.GetInstance().GetStashPaths().Paths()
if err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil {
return fmt.Errorf("creating missing folder hierarchies: %w", err)
}
if err := m.fixIncorrectParents(ctx, rootPaths); err != nil {
return fmt.Errorf("fixing incorrect parent folders: %w", err)
}
if err := m.migrateFolders(ctx); err != nil {
return fmt.Errorf("migrating folders: %w", err)
}
return nil
}
type schema84Migrator struct {
migrator
folderCache map[string]folderInfo
}
func (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error {
// before we set the basenames, we need to address any folders that are missing their
// parent folders.
const (
limit = 1000
logEvery = 10000
)
lastID := 0
count := 0
logged := false
for {
gotSome := false
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL "
if lastID != 0 {
query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID)
}
query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit)
rows, err := tx.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
// log once if we find any folders with missing parent folders
if !logged {
logger.Info("Migrating folders with missing parents...")
logged = true
}
var id int
var p string
err := rows.Scan(&id, &p)
if err != nil {
return err
}
lastID = id
gotSome = true
count++
// don't try to create parent folders for root paths
if slices.Contains(rootPaths, p) {
continue
}
parentDir := filepath.Dir(p)
if parentDir == p {
// this can happen if the path is something like "C:\", where the parent directory is the same as the current directory
continue
}
parentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths)
if err != nil {
return fmt.Errorf("error creating parent folder for folder %d %q: %w", id, p, err)
}
if parentID == nil {
continue
}
// now set the parent folder ID for the current folder
logger.Debugf("Migrating folder %d %q: setting parent folder ID to %d", id, p, *parentID)
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *parentID, id)
if err != nil {
return fmt.Errorf("error setting parent folder for folder %d %q: %w", id, p, err)
}
}
return rows.Err()
}); err != nil {
return err
}
if !gotSome {
break
}
if count%logEvery == 0 {
logger.Infof("Migrated %d folders", count)
}
}
return nil
}
func (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) {
query := "SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?"
var id int
if err := tx.Get(&id, query, path); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &id, nil
}
// this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go,
// but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid
func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) {
// get or create folder hierarchy
folderID, err := m.findFolderByPath(tx, path)
if err != nil {
return nil, err
}
if folderID == nil {
var parentID *int
if !slices.Contains(rootPaths, path) {
parentPath := filepath.Dir(path)
// it's possible that the parent path is the same as the current path, if there are folders outside
// of the root paths. In that case, we should just return nil for the parent ID.
if parentPath == path {
return nil, nil
}
parentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths)
if err != nil {
return nil, err
}
}
logger.Debugf("%s doesn't exist. Creating new folder entry...", path)
// we need to set basename to path, which will be addressed in the next step
const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)"
var parentFolderID null.Int
if parentID != nil {
parentFolderID = null.IntFrom(int64(*parentID))
}
now := time.Now()
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)
if err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
idInt := int(id)
folderID = &idInt
}
return folderID, nil
}
func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error {
const (
limit = 1000
logEvery = 10000
)
lastID := 0
count := 0
fixed := 0
logged := false
for {
gotSome := false
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
query := "SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path " +
"FROM folders f " +
"JOIN folders pf ON f.parent_folder_id = pf.id "
if lastID != 0 {
query += fmt.Sprintf("WHERE f.id > %d ", lastID)
}
query += fmt.Sprintf("ORDER BY f.id LIMIT %d", limit)
rows, err := tx.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int
var p string
var parentFolderID int
var parentPath string
err := rows.Scan(&id, &p, &parentFolderID, &parentPath)
if err != nil {
return err
}
lastID = id
gotSome = true
count++
expectedParent := filepath.Dir(p)
if expectedParent == parentPath {
continue
}
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
if err != nil {
return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err)
}
if correctParentID == nil {
continue
}
logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID)
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id)
if err != nil {
return fmt.Errorf("error fixing parent folder for folder %d %q: %w", id, p, err)
}
fixed++
}
return rows.Err()
}); err != nil {
return err
}
if !gotSome {
break
}
if count%logEvery == 0 {
logger.Infof("Checked %d folders", count)
}
}
if fixed > 0 {
logger.Infof("Fixed %d folders with incorrect parent assignments", fixed)
}
return nil
}
func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
const (
limit = 1000
logEvery = 10000
)
lastID := 0
count := 0
logged := false
for {
gotSome := false
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` "
if lastID != 0 {
query += fmt.Sprintf("WHERE `folders`.`id` > %d ", lastID)
}
query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit)
rows, err := tx.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
if !logged {
logger.Infof("Migrating folders to set basenames...")
logged = true
}
var id int
var p string
err := rows.Scan(&id, &p)
if err != nil {
return err
}
lastID = id
gotSome = true
count++
basename := filepath.Base(p)
logger.Debugf("Migrating folder %d %q: setting basename to %q", id, p, basename)
_, err = tx.Exec("UPDATE `folders` SET `basename` = ? WHERE `id` = ?", basename, id)
if err != nil {
return fmt.Errorf("error migrating folder %d %q: %w", id, p, err)
}
}
return rows.Err()
}); err != nil {
return err
}
if !gotSome {
break
}
if count%logEvery == 0 {
logger.Infof("Migrated %d folders", count)
}
}
return nil
}
func init() {
sqlite.RegisterPostMigration(84, post84)
}

View File

@@ -195,6 +195,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
qb.tagCountCriterionHandler(filter.TagCount),
qb.sceneCountCriterionHandler(filter.SceneCount),
qb.markerCountCriterionHandler(filter.MarkerCount),
qb.imageCountCriterionHandler(filter.ImageCount),
qb.galleryCountCriterionHandler(filter.GalleryCount),
qb.playCounterCriterionHandler(filter.PlayCount),
@@ -204,6 +205,16 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
&timestampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil},
&timestampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil},
&relatedFilterHandler{
relatedIDCol: "scene_markers.id",
relatedRepo: sceneMarkerRepository.repository,
relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter},
joinFn: func(f *filterBuilder) {
performerRepository.scenes.innerJoin(f, "", "performers.id")
f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id")
},
},
&relatedFilterHandler{
relatedIDCol: "performers_scenes.scene_id",
relatedRepo: sceneRepository.repository,
@@ -305,7 +316,19 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *
case "aliases":
performersAliasesTableMgr.join(f, "", "performers.id")
f.addWhere("performer_aliases.alias IS NULL")
case "tags":
f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id")
f.addWhere("tags_join.performer_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"disambiguation", "gender", "birthdate", "death_date",
"ethnicity", "country", "hair_color", "eye_color", "height", "weight",
"measurements", "fake_tits", "penis_length", "circumcised",
"career_start", "career_end", "tattoos", "piercings", "details", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')")
}
}
@@ -387,6 +410,22 @@ func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCr
return h.handler(count)
}
func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if count != nil {
performerRepository.scenes.innerJoin(f, "", "performers.id")
const query = `(SELECT COUNT(*) FROM scene_markers
INNER JOIN scenes ON scene_markers.scene_id = scenes.id
INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id
WHERE performers_scenes.performer_id = performers.id)`
clause, args := getIntCriterionWhereClause(query, *count)
f.addWhere(clause, args...)
}
}
}
func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: performerTable,

View File

@@ -17,13 +17,26 @@ type queryBuilder struct {
joins joins
whereClauses []string
havingClauses []string
args []interface{}
withClauses []string
recursiveWith bool
withArgs []interface{}
joinArgs []interface{}
whereArgs []interface{}
havingArgs []interface{}
sortAndPagination string
}
func (qb queryBuilder) allArgs() []interface{} {
var args []interface{}
args = append(args, qb.withArgs...)
args = append(args, qb.joinArgs...)
args = append(args, qb.whereArgs...)
args = append(args, qb.havingArgs...)
return args
}
func (qb queryBuilder) body(includeSortPagination bool) string {
return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination))
}
@@ -55,13 +68,13 @@ func (qb queryBuilder) toSQL(includeSortPagination bool) string {
func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) {
const includeSortPagination = true
sql := qb.toSQL(includeSortPagination)
return qb.repository.runIdsQuery(ctx, sql, qb.args)
return qb.repository.runIdsQuery(ctx, sql, qb.allArgs())
}
func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) {
const includeSortPagination = true
body := qb.body(includeSortPagination)
return qb.repository.executeFindQuery(ctx, body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith)
return qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith)
}
func (qb queryBuilder) executeCount(ctx context.Context) (int, error) {
@@ -79,7 +92,7 @@ func (qb queryBuilder) executeCount(ctx context.Context) (int, error) {
body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)
countQuery := withClause + qb.repository.buildCountQuery(body)
return qb.repository.runCountQuery(ctx, countQuery, qb.args)
return qb.repository.runCountQuery(ctx, countQuery, qb.allArgs())
}
func (qb *queryBuilder) addWhere(clauses ...string) {
@@ -109,7 +122,11 @@ func (qb *queryBuilder) addWith(recursive bool, clauses ...string) {
}
func (qb *queryBuilder) addArg(args ...interface{}) {
qb.args = append(qb.args, args...)
qb.whereArgs = append(qb.whereArgs, args...)
}
func (qb *queryBuilder) addHavingArg(args ...interface{}) {
qb.havingArgs = append(qb.havingArgs, args...)
}
func (qb *queryBuilder) hasJoin(alias string) bool {
@@ -148,7 +165,7 @@ func (qb *queryBuilder) joinSort(table, as, onClause string) {
func (qb *queryBuilder) addJoins(joins ...join) {
for _, j := range joins {
if qb.joins.addUnique(j) {
qb.args = append(qb.args, j.args...)
qb.joinArgs = append(qb.joinArgs, j.args...)
}
}
}
@@ -163,20 +180,16 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error {
if len(clause) > 0 {
qb.addWith(f.recursiveWith, clause)
}
if len(args) > 0 {
// WITH clause always comes first and thus precedes alk args
qb.args = append(args, qb.args...)
qb.withArgs = append(qb.withArgs, args...)
}
// add joins here to insert args
qb.addJoins(f.getAllJoins()...)
clause, args = f.generateWhereClauses()
if len(clause) > 0 {
qb.addWhere(clause)
}
if len(args) > 0 {
qb.addArg(args...)
}
@@ -185,9 +198,8 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error {
if len(clause) > 0 {
qb.addHaving(clause)
}
if len(args) > 0 {
qb.addArg(args...)
qb.addHavingArg(args...)
}
return nil

View File

@@ -1097,7 +1097,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
Duration null.Float
Size null.Float
}{}
if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}

View File

@@ -426,6 +426,12 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
case "cover":
f.addWhere("scenes.cover_blob IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"title", "code", "details", "director", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')")
}
}

View File

@@ -31,7 +31,8 @@ const (
)
const (
folderIdxWithSubFolder = iota
folderIdxRoot = iota
folderIdxWithSubFolder
folderIdxWithParentFolder
folderIdxWithFiles
folderIdxInZip
@@ -359,6 +360,8 @@ func (m linkMap) reverseLookup(idx int) []int {
var (
folderParentFolders = map[int]int{
folderIdxWithSubFolder: folderIdxRoot,
folderIdxForObjectFiles: folderIdxRoot,
folderIdxWithParentFolder: folderIdxWithSubFolder,
folderIdxWithSceneFiles: folderIdxForObjectFiles,
folderIdxWithImageFiles: folderIdxForObjectFiles,
@@ -785,6 +788,10 @@ func getFolderPath(index int, parentFolderIdx *int) string {
return path
}
func getFolderBasename(index int, parentFolderIdx *int) string {
return filepath.Base(getFolderPath(index, parentFolderIdx))
}
func getFolderModTime(index int) time.Time {
return time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC)
}

View File

@@ -71,6 +71,16 @@ func (o sortOptions) validateSort(sort string) error {
return fmt.Errorf("invalid sort: %s", sort)
}
func validateIsMissing(isMissing string, allowed []string) error {
for _, v := range allowed {
if v == isMissing {
return nil
}
}
return fmt.Errorf("invalid is_missing field: %s", isMissing)
}
func getSortDirection(direction string) string {
if direction != "ASC" && direction != "DESC" {
return "ASC"

View File

@@ -150,7 +150,19 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit
case "stash_id":
studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id")
f.addWhere("studio_stash_ids.studio_id IS NULL")
case "aliases":
studiosAliasesTableMgr.join(f, "", "studios.id")
f.addWhere("studio_aliases.alias IS NULL")
case "tags":
f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id")
f.addWhere("tags_join.studio_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"details", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')")
}
}

View File

@@ -161,6 +161,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
tagRepository.studios.innerJoin(f, "", "tags.id")
},
},
&relatedFilterHandler{
relatedIDCol: "markers_tags.marker_id",
relatedRepo: sceneMarkerRepository.repository,
relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter},
joinFn: func(f *filterBuilder) {
f.addWith(`markers_tags AS (
SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt
UNION
SELECT m.id, m.primary_tag_id FROM scene_markers m
)`)
f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id")
},
},
}
}
@@ -184,7 +198,19 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri
switch *isMissing {
case "image":
f.addWhere("tags.image_blob IS NULL")
case "aliases":
tagRepository.aliases.join(f, "", "tags.id")
f.addWhere("tag_aliases.alias IS NULL")
case "stash_id":
tagRepository.stashIDs.join(f, "tag_stash_ids", "tags.id")
f.addWhere("tag_stash_ids.tag_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"description",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')")
}
}

View File

@@ -1889,6 +1889,65 @@ func TestTagQueryCustomFields(t *testing.T) {
}
})
}
// Test combining text search (findFilter.Q) with custom field filters.
// This verifies that positional args are bound in the correct order
// when JOINs (from custom fields) and WHERE (from text search) both
// have parameterized placeholders.
runWithRollbackTxn(t, "equals with text search", func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
tagName := getTagStringValue(tagIdxWithGallery, "Name")
q := tagName
findFilter := &models.FindFilterType{Q: &q}
tagFilter := &models.TagFilterType{
CustomFields: []models.CustomFieldCriterionInput{
{
Field: "string",
Modifier: models.CriterionModifierEquals,
Value: []any{getTagStringValue(tagIdxWithGallery, "custom")},
},
},
}
tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)
if err != nil {
t.Errorf("TagStore.Query() error = %v", err)
return
}
ids := tagsToIDs(tags)
assert.Contains(ids, tagIDs[tagIdxWithGallery])
assert.Len(tags, 1)
})
runWithRollbackTxn(t, "is_null with text search", func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
tagName := getTagStringValue(tagIdxWithGallery, "Name")
q := tagName
findFilter := &models.FindFilterType{Q: &q}
tagFilter := &models.TagFilterType{
CustomFields: []models.CustomFieldCriterionInput{
{
Field: "not existing",
Modifier: models.CriterionModifierIsNull,
},
},
}
tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)
if err != nil {
t.Errorf("TagStore.Query() error = %v", err)
return
}
ids := tagsToIDs(tags)
assert.Contains(ids, tagIDs[tagIdxWithGallery])
assert.Len(tags, 1)
})
}
// TODO Destroy

88
ui/v2.5/pnpm-lock.yaml generated
View File

@@ -1660,36 +1660,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -1781,56 +1787,67 @@ packages:
resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.1':
resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.1':
resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.1':
resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.1':
resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.1':
resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.1':
resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.1':
resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.1':
resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.1':
resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.1':
resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.1':
resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==}
@@ -2195,11 +2212,11 @@ packages:
peerDependencies:
ajv: ^6.9.1
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
@@ -2350,6 +2367,10 @@ packages:
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-blob@1.4.1:
resolution: {integrity: sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw==}
@@ -2382,8 +2403,9 @@ packages:
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.3:
resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -3895,11 +3917,11 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
minimatch@9.0.8:
resolution: {integrity: sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==}
engines: {node: '>=16 || 14 >=14.17'}
minimist-options@4.1.0:
@@ -6336,14 +6358,14 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
ajv: 6.14.0
debug: 4.4.3
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.0
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -7037,7 +7059,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3
minimatch: 3.1.2
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -7663,18 +7685,18 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ajv-keywords@3.5.2(ajv@6.12.6):
ajv-keywords@3.5.2(ajv@6.14.0):
dependencies:
ajv: 6.12.6
ajv: 6.14.0
ajv@6.12.6:
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@8.17.1:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
@@ -7887,6 +7909,8 @@ snapshots:
balanced-match@2.0.0: {}
balanced-match@4.0.4: {}
base64-blob@1.4.1:
dependencies:
b64-to-blob: 1.2.19
@@ -7924,9 +7948,9 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
brace-expansion@5.0.3:
dependencies:
balanced-match: 1.0.2
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
@@ -8520,7 +8544,7 @@ snapshots:
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
minimatch: 3.1.2
minimatch: 3.1.5
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
@@ -8548,7 +8572,7 @@ snapshots:
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
minimatch: 3.1.2
minimatch: 3.1.5
object.fromentries: 2.0.8
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
@@ -8569,7 +8593,7 @@ snapshots:
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
minimatch: 3.1.2
minimatch: 3.1.5
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
@@ -8601,7 +8625,7 @@ snapshots:
'@humanwhocodes/module-importer': 1.0.1
'@nodelib/fs.walk': 1.2.8
'@ungap/structured-clone': 1.3.0
ajv: 6.12.6
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@@ -8626,7 +8650,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 3.1.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
strip-ansi: 6.0.1
@@ -8874,7 +8898,7 @@ snapshots:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
@@ -8932,7 +8956,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@4.8.4)
graphql: 16.11.0
jiti: 2.6.1
minimatch: 9.0.5
minimatch: 9.0.8
string-env-interpolation: 1.0.1
tslib: 2.8.1
transitivePeerDependencies:
@@ -9700,13 +9724,13 @@ snapshots:
min-indent@1.0.1: {}
minimatch@3.1.2:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
minimatch@9.0.5:
minimatch@9.0.8:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 5.0.3
minimist-options@4.1.0:
dependencies:
@@ -10497,8 +10521,8 @@ snapshots:
schema-utils@2.7.1:
dependencies:
'@types/json-schema': 7.0.15
ajv: 6.12.6
ajv-keywords: 3.5.2(ajv@6.12.6)
ajv: 6.14.0
ajv-keywords: 3.5.2(ajv@6.14.0)
scuid@1.1.0: {}
@@ -10827,7 +10851,7 @@ snapshots:
table@6.9.0:
dependencies:
ajv: 8.17.1
ajv: 8.18.0
lodash.truncate: 4.4.2
slice-ansi: 4.0.0
string-width: 4.2.3

View File

@@ -0,0 +1,79 @@
import React from "react";
import { Link } from "react-router-dom";
import Slider from "@ant-design/react-slick";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { UnsupportedCriterion } from "src/models/list-filter/criteria/criterion";
import { PopoverCard, WarningHoverPopover } from "../Shared/HoverPopover";
interface IProps {
className?: string;
isTouch: boolean;
filter: ListFilterModel;
heading: string;
count: number;
loading: boolean;
url: string;
}
export const FilteredRecommendationRow: React.FC<IProps> = PatchComponent(
"FilteredRecommendationRow",
(props) => {
const cardCount = props.count;
const unsupportedCriteria = props.filter.criteria.filter(
(criterion) => criterion instanceof UnsupportedCriterion
);
const header = unsupportedCriteria.length ? (
<div>
<span>{props.heading}</span>
<WarningHoverPopover
placement="top"
content={
<PopoverCard>
<FormattedMessage
id="unsupported_criteria"
values={{
criteria: unsupportedCriteria
.map((c) => c.criterionOption.type)
.join(", "),
}}
/>
</PopoverCard>
}
/>
</div>
) : (
props.heading
);
if (!props.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className={props.className}
header={header}
link={
<Link to={props.url}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{props.children}
</Slider>
</RecommendationRow>
);
}
);

View File

@@ -3,7 +3,7 @@ import { PatchComponent } from "src/patch";
interface IProps {
className?: string;
header: string;
header: React.ReactNode;
link: JSX.Element;
}

View File

@@ -2,7 +2,7 @@ import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { showWhenSelected } from "src/components/List/ItemList";
import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
@@ -100,7 +100,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
];
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
extraOperations={otherOperations}
alterQuery={active}

View File

@@ -2,7 +2,7 @@ import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import {
mutateRemoveGalleryImages,
mutateSetGalleryCover,
@@ -142,7 +142,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
];
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
extraOperations={otherOperations}

View File

@@ -49,6 +49,8 @@ import {
IItemListOperation,
} from "../List/FilteredListToolbar";
import { FilterTags } from "../List/FilterTags";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries";
const GalleryList: React.FC<{
galleries: GQL.SlimGalleryDataFragment[];
@@ -169,6 +171,14 @@ const SidebarContent: React.FC<{
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="organized"
/>
<SidebarAgeFilter
title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="performer_age"
/>
</GalleryFilterSidebarSections>
@@ -282,7 +292,7 @@ export const FilteredGalleryList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@@ -313,7 +323,7 @@ export const FilteredGalleryList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindGalleries } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { GalleryCard } from "./GalleryCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,40 +15,29 @@ export const GalleryRecommendationRow: React.FC<IProps> = PatchComponent(
"GalleryRecommendationRow",
(props) => {
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findGalleries.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/galleries?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -265,7 +265,7 @@ export const FilteredGroupList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@@ -296,7 +296,7 @@ export const FilteredGroupList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindGroups } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { GroupCard } from "./GroupCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,40 +15,26 @@ export const GroupRecommendationRow: React.FC<IProps> = PatchComponent(
"GroupRecommendationRow",
(props: IProps) => {
const result = useFindGroups(props.filter);
const cardCount = result.data?.findGroups.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findGroups.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="group-recommendations"
header={props.header}
link={
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/groups?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="group-skeleton skeleton-card"
></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="group-skeleton skeleton-card"></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -1,5 +1,11 @@
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
import { FormattedNumber, useIntl } from "react-intl";
import React, {
useCallback,
useState,
useMemo,
MouseEvent,
useEffect,
} from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
@@ -9,11 +15,10 @@ import {
useFindImages,
useFindImagesMetadata,
} from "src/core/StashService";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { useFilteredItemList } from "../List/ItemList";
import { useLightbox } from "src/hooks/Lightbox/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { ImageWallItem } from "./ImageWallItem";
import { EditImagesDialog } from "./EditImagesDialog";
import { DeleteImagesDialog } from "./DeleteImagesDialog";
@@ -24,11 +29,43 @@ import { objectTitle } from "src/core/files";
import { useConfigurationContext } from "src/hooks/Config";
import { ImageCardGrid } from "./ImageCardGrid";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import {
FilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import { FileSize } from "../Shared/FileSize";
import { PatchComponent } from "src/patch";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { useModal } from "src/hooks/modal";
import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import {
IListFilterOperation,
ListOperations,
} from "../List/ListOperationButtons";
import { FilterTags } from "../List/FilterTags";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { LoadedContent } from "../List/PagedList";
import useFocus from "src/utils/focus";
import cx from "classnames";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { Button } from "react-bootstrap";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/images";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@@ -180,131 +217,125 @@ interface IImageListImages {
chapters?: GQL.GalleryChapterDataFragment[];
}
const ImageListImages: React.FC<IImageListImages> = ({
images,
filter,
selectedIds,
onChangePage,
pageCount,
onSelectChange,
slideshowRunning,
setSlideshowRunning,
chapters = [],
}) => {
const handleLightBoxPage = useCallback(
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
if (direction !== undefined) {
if (direction < 0) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage + direction);
}
} else if (direction > 0) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + direction);
}
}
} else if (newPage !== undefined) {
onChangePage(newPage);
}
},
[onChangePage, filter.currentPage, pageCount]
);
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const lightboxState = useMemo(() => {
return {
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
page: filter.currentPage,
pages: pageCount,
pageSize: filter.itemsPerPage,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
}, [
const ImageList: React.FC<IImageListImages> = PatchComponent(
"ImageList",
({
images,
filter,
selectedIds,
onChangePage,
pageCount,
filter.currentPage,
filter.itemsPerPage,
onSelectChange,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
setSlideshowRunning,
chapters = [],
}) => {
const handleLightBoxPage = useCallback(
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
const showLightbox = useLightbox(
lightboxState,
filter.sortBy === "path" &&
filter.sortDirection === GQL.SortDirectionEnum.Asc
? chapters
: []
);
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox({ initialIndex: index, slideshowEnabled: true });
},
[showLightbox, setSlideshowRunning]
);
function onPreview(index: number, ev: MouseEvent) {
handleImageOpen(index);
ev.preventDefault();
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
onPreview={onPreview}
/>
if (direction !== undefined) {
if (direction < 0) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage + direction);
}
} else if (direction > 0) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + direction);
}
}
} else if (newPage !== undefined) {
onChangePage(newPage);
}
},
[onChangePage, filter.currentPage, pageCount]
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<ImageWall
images={images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const lightboxState = useMemo(() => {
return {
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
page: filter.currentPage,
pages: pageCount,
pageSize: filter.itemsPerPage,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
}, [
images,
pageCount,
filter.currentPage,
filter.itemsPerPage,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
const showLightbox = useLightbox(
lightboxState,
filter.sortBy === "path" &&
filter.sortDirection === GQL.SortDirectionEnum.Asc
? chapters
: []
);
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox({ initialIndex: index, slideshowEnabled: true });
},
[showLightbox, setSlideshowRunning]
);
function onPreview(index: number, ev: MouseEvent) {
handleImageOpen(index);
ev.preventDefault();
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
onPreview={onPreview}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<ImageWall
images={images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
);
}
// should not happen
return <></>;
}
// should not happen
return <></>;
};
function getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
}
function getCount(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.count ?? 0;
}
);
function renderMetadataByline(
result: GQL.FindImagesQueryResult,
metadataInfo?: GQL.FindImagesMetadataQueryResult
metadataInfo: GQL.FindImagesMetadataQueryResult | undefined
) {
const megapixels = metadataInfo?.data?.findImages?.megapixels;
const size = metadataInfo?.data?.findImages?.filesize;
@@ -339,6 +370,130 @@ function renderMetadataByline(
);
}
const ImageFilterSidebarSections = PatchContainerComponent(
"FilteredImageList.SidebarSections"
);
const SidebarContent: React.FC<{
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
count?: number;
focus?: ReturnType<typeof useFocus>;
}> = ({
filter,
setFilter,
filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
const hideStudios = view === View.StudioScenes;
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<ImageFilterSidebarSections>
{!hideStudios && (
<SidebarStudiosFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
)}
<SidebarPerformersFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="organized"
/>
<SidebarAgeFilter
title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="performer_age"
/>
</ImageFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
function useViewRandom(filter: ListFilterModel, count: number) {
const history = useHistory();
const viewRandom = useCallback(async () => {
// query for a random image
if (count === 0) {
return;
}
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
}
}, [history, filter, count]);
return viewRandom;
}
function useAddKeybinds(filter: ListFilterModel, count: number) {
const viewRandom = useViewRandom(filter, count);
useEffect(() => {
Mousetrap.bind("p r", () => {
viewRandom();
});
return () => {
Mousetrap.unbind("p r");
};
}, [viewRandom]);
}
interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
@@ -347,28 +502,185 @@ interface IImageList {
chapters?: GQL.GalleryChapterDataFragment[];
}
export const ImageList: React.FC<IImageList> = PatchComponent(
"ImageList",
({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => {
export const FilteredImageList = PatchComponent(
"FilteredImageList",
(props: IImageList) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const filterMode = GQL.FilterMode.Images;
const searchFocus = useFocus();
const { modal, showModal, closeModal } = useModal();
const withSidebar = props.view !== View.GalleryImages;
const otherOperations: IItemListOperation<GQL.FindImagesQueryResult>[] = [
...extraOperations,
const {
filterHook,
view,
alterQuery,
extraOperations: providedOperations = [],
chapters,
} = props;
// States
const {
showSidebar,
setShowSidebar,
sectionOpen,
setSectionOpen,
loading: sidebarStateLoading,
} = useSidebarState(view);
const {
filterState,
queryResult,
metadataInfo,
modalState,
listSelect,
showEditFilter,
} = useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Images,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindImages,
useMetadataInfo: useFindImagesMetadata,
getCount: (r) => r.data?.findImages.count ?? 0,
getItems: (r) => r.data?.findImages.images ?? [],
filterHook,
},
});
const { filter, setFilter } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const metadataByline = useMemo(() => {
if (cachedResult.loading) return null;
return renderMetadataByline(metadataInfo) ?? null;
}, [cachedResult.loading, metadataInfo]);
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(filter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
});
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
onEdit?.();
}
});
Mousetrap.bind("d d", () => {
if (hasSelection) {
onDelete?.();
}
});
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
images: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
function onEdit() {
showModal(
<EditImagesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
function onDelete() {
showModal(
<DeleteImagesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
const convertedExtraOperations: IListFilterOperation[] =
providedOperations.map((o) => ({
...o,
isDisplayed: o.isDisplayed
? () => o.isDisplayed!(result, filter, selectedIds)
: undefined,
onClick: () => {
o.onClick(result, filter, selectedIds);
},
}));
const otherOperations: IListFilterOperation[] = [
...convertedExtraOperations,
{
text: intl.formatMessage({ id: "actions.select_all" }),
onClick: () => onSelectAll(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.select_none" }),
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: (result, filter, selectedIds) => {
onClick: () => {
showModal(
<GenerateDialog
type="image"
@@ -376,101 +688,78 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
onClose={() => closeModal()}
/>
);
return Promise.resolve();
},
isDisplayed: showWhenSelected,
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
onClick: () => onExport(true),
},
];
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
// render
if (sidebarStateLoading) return null;
return () => {
Mousetrap.unbind("p r");
};
}
const operations = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
operationsMenuClassName="image-list-operations-dropdown"
/>
);
async function viewRandom(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findImages) {
const { count } = result.data.findImages;
const pageCount = Math.ceil(totalCount / filter.itemsPerPage);
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
}
}
}
const content = (
<>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
zoomable
/>
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
</div>
function renderContent(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (
id: string,
selected: boolean,
shiftKey: boolean
) => void,
onChangePage: (page: number) => void,
pageCount: number
) {
function maybeRenderImageExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
images: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
function renderImages() {
if (!result.data?.findImages) return;
return (
<ImageListImages
<LoadedContent loading={result.loading} error={result.error}>
<ImageList
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
images={items}
onChangePage={(page) => setFilter(filter.changePage(page))}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}
@@ -478,54 +767,60 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
setSlideshowRunning={setSlideshowRunning}
chapters={chapters}
/>
);
}
</LoadedContent>
return (
<>
{maybeRenderImageExportDialog()}
{renderImages()}
</>
);
}
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
metadataByline={metadataByline}
/>
</div>
</div>
)}
</>
);
function renderEditDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
if (!withSidebar) {
return content;
}
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindImages}
useMetadataInfo={useFindImagesMetadata}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
<div
className={cx("item-list-container image-list", {
"hide-sidebar": !showSidebar,
})}
>
{modal}
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
{content}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindImages } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { ImageCard } from "./ImageCard";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,40 +15,26 @@ export const ImageRecommendationRow: React.FC<IProps> = PatchComponent(
"ImageRecommendationRow",
(props: IProps) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findImages.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="images-recommendations"
header={props.header}
link={
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/images?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="image-skeleton skeleton-card"
></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="image-skeleton skeleton-card"></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -3,11 +3,11 @@ import { Route, Switch } from "react-router-dom";
import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Image from "./ImageDetails/Image";
import { ImageList } from "./ImageList";
import { FilteredImageList } from "./ImageList";
import { View } from "../List/views";
const Images: React.FC = () => {
return <ImageList view={View.Images} />;
return <FilteredImageList view={View.Images} />;
};
const ImageRoutes: React.FC = () => {

View File

@@ -6,10 +6,17 @@ import React, {
useRef,
} from "react";
import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import {
Criterion,
UnsupportedCriterion,
} from "src/models/list-filter/criteria/criterion";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon";
import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons";
import {
faExclamationTriangle,
faMagnifyingGlass,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
import { useDebounce } from "src/hooks/debounce";
@@ -38,9 +45,20 @@ export const FilterTag: React.FC<{
label: React.ReactNode;
onClick: React.MouseEventHandler<HTMLSpanElement>;
onRemove: React.MouseEventHandler<HTMLElement>;
}> = ({ className, label, onClick, onRemove }) => {
unsupported?: boolean;
}> = ({ className, label, onClick, onRemove, unsupported }) => {
function handleClick(e: React.MouseEvent<HTMLSpanElement, MouseEvent>) {
if (unsupported) {
return;
}
onClick(e);
}
return (
<TagItem className={className} onClick={onClick}>
<TagItem className={cx(className, { unsupported })} onClick={handleClick}>
{unsupported && (
<Icon icon={faExclamationTriangle} className="unsupported-icon" />
)}
{label}
<Button
variant="secondary"
@@ -271,10 +289,13 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
});
}
const unsupported = criterion instanceof UnsupportedCriterion;
return (
<FilterTag
key={criterion.getId()}
label={criterion.getLabel(intl, sfwContentMode)}
unsupported={unsupported}
onClick={() => onClickCriterionTag(criterion)}
onRemove={($event) => onRemoveCriterionTag(criterion, $event)}
/>

View File

@@ -20,10 +20,12 @@ import {
FilterMode,
GalleryFilterType,
GroupFilterType,
ImageFilterType,
InputMaybe,
IntCriterionInput,
PerformerFilterType,
SceneFilterType,
SceneMarkerFilterType,
StudioFilterType,
} from "src/core/generated-graphql";
import { useIntl } from "react-intl";
@@ -523,10 +525,14 @@ interface IFilterType {
performer_count?: InputMaybe<IntCriterionInput>;
galleries_filter?: InputMaybe<GalleryFilterType>;
gallery_count?: InputMaybe<IntCriterionInput>;
images_filter?: InputMaybe<ImageFilterType>;
image_count?: InputMaybe<IntCriterionInput>;
groups_filter?: InputMaybe<GroupFilterType>;
group_count?: InputMaybe<IntCriterionInput>;
studios_filter?: InputMaybe<StudioFilterType>;
studio_count?: InputMaybe<IntCriterionInput>;
marker_count?: InputMaybe<IntCriterionInput>;
markers_filter?: InputMaybe<SceneMarkerFilterType>;
}
export function setObjectFilter(
@@ -549,6 +555,7 @@ export function setObjectFilter(
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.scenes_filter = relatedFilterOutput as SceneFilterType;
break;
@@ -559,6 +566,7 @@ export function setObjectFilter(
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.performers_filter = relatedFilterOutput as PerformerFilterType;
break;
@@ -569,9 +577,21 @@ export function setObjectFilter(
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.galleries_filter = relatedFilterOutput as GalleryFilterType;
break;
case FilterMode.Images:
// if empty, only get objects with galleries
if (empty) {
out.image_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.images_filter = relatedFilterOutput as ImageFilterType;
break;
case FilterMode.Groups:
// if empty, only get objects with groups
if (empty) {
@@ -579,6 +599,7 @@ export function setObjectFilter(
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.groups_filter = relatedFilterOutput as GroupFilterType;
break;
@@ -589,9 +610,21 @@ export function setObjectFilter(
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.studios_filter = relatedFilterOutput as StudioFilterType;
break;
case FilterMode.SceneMarkers:
// if empty, only get objects with scene markers
if (empty) {
out.marker_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.markers_filter = relatedFilterOutput as SceneMarkerFilterType;
break;
default:
throw new Error("Invalid filter mode");
}

View File

@@ -1,33 +1,11 @@
import React, {
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
EditFilterDialog,
useShowEditFilter,
} from "src/components/List/EditFilterDialog";
import { FilterTags } from "./FilterTags";
import { View } from "./views";
import { useShowEditFilter } from "src/components/List/EditFilterDialog";
import { IHasID } from "src/utils/data";
import {
ListContext,
QueryResultContext,
useListContext,
useQueryResultContext,
} from "./ListProvider";
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
import { useModal } from "src/hooks/modal";
import {
IFilterStateHook,
IQueryResultHook,
useDefaultFilter,
useEnsureValidPage,
useFilterOperations,
useFilterState,
@@ -36,26 +14,23 @@ import {
useQueryResult,
useScrollToTopOnPageChange,
} from "./util";
import {
FilteredListToolbar,
IFilteredListToolbar,
IItemListOperation,
} from "./FilteredListToolbar";
import { PagedList } from "./PagedList";
import { useConfigurationContext } from "src/hooks/Config";
import { useZoomKeybinds } from "./ZoomSlider";
import { DisplayMode } from "src/models/list-filter/types";
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
interface IFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID,
M = unknown
> {
filterStateProps: IFilterStateHook;
queryResultProps: IQueryResultHook<T, E>;
queryResultProps: IQueryResultHook<T, E, M>;
}
// Provides the common state and behaviour for filtered item list components
export function useFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID
>(props: IFilteredItemList<T, E>) {
E extends IHasID = IHasID,
M = unknown
>(props: IFilteredItemList<T, E, M>) {
const { configuration: config } = useConfigurationContext();
// States
@@ -70,7 +45,7 @@ export function useFilteredItemList<
filter,
...props.queryResultProps,
});
const { result, items, totalCount, pages } = queryResult;
const { result, items, totalCount, pages, metadataInfo } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;
@@ -107,352 +82,13 @@ export function useFilteredItemList<
return {
filterState,
queryResult,
metadataInfo,
listSelect,
modalState,
showEditFilter,
};
}
interface IItemListProps<T extends QueryResult, E extends IHasID, M = unknown> {
view?: View;
otherOperations?: IItemListOperation<T>[];
renderContent: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
onChangePage: (page: number) => void,
pageCount: number
) => React.ReactNode;
renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode;
renderEditDialog?: (
selected: E[],
onClose: (applied: boolean) => void
) => React.ReactNode;
renderDeleteDialog?: (
selected: E[],
onClose: (confirmed: boolean) => void
) => React.ReactNode;
addKeybinds?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => () => void;
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
}
export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
props: IItemListProps<T, E, M>
) => {
const {
view,
otherOperations,
renderContent,
renderEditDialog,
renderDeleteDialog,
renderMetadataByline,
addKeybinds,
renderToolbar: providedToolbar,
} = props;
const { filter, setFilter: updateFilter } = useFilter();
const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } =
useQueryResultContext<T, E, M>();
const listSelect = useListContext<E>();
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
} = listSelect;
// scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage, result.loading);
const { modal, showModal, closeModal } = useModal();
const metadataByline = useMemo(() => {
if (cachedResult.loading) return "";
return renderMetadataByline?.(cachedResult, metadataInfo) ?? "";
}, [renderMetadataByline, cachedResult, metadataInfo]);
const pages = Math.ceil(totalCount / filter.itemsPerPage);
const onChangePage = useCallback(
(p: number) => {
updateFilter(filter.changePage(p));
},
[filter, updateFilter]
);
useEnsureValidPage(filter, totalCount, updateFilter);
const showEditFilter = useCallback(
(editingCriterion?: string) => {
function onApplyEditFilter(f: ListFilterModel) {
closeModal();
updateFilter(f);
}
showModal(
<EditFilterDialog
filter={filter}
onApply={onApplyEditFilter}
onCancel={() => closeModal()}
editingCriterion={editingCriterion}
/>
);
},
[filter, updateFilter, showModal, closeModal]
);
useListKeyboardShortcuts({
currentPage: filter.currentPage,
onChangePage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});
const zoomable =
filter.displayMode === DisplayMode.Grid ||
filter.displayMode === DisplayMode.Wall;
useZoomKeybinds({
zoomIndex: zoomable ? filter.zoomIndex : undefined,
onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)),
});
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
return () => {
unbindExtras();
};
}
}, [addKeybinds, result, effectiveFilter, selectedIds]);
const operations = useMemo(() => {
async function onOperationClicked(o: IItemListOperation<T>) {
await o.onClick(result, effectiveFilter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
}
return otherOperations?.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, effectiveFilter, selectedIds);
}
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
}, [result, effectiveFilter, selectedIds, otherOperations]);
function onEdit() {
if (!renderEditDialog) {
return;
}
showModal(
renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
);
}
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
closeModal();
// refetch
result.refetch();
}
function onDelete() {
if (!renderDeleteDialog) {
return;
}
showModal(
renderDeleteDialog(getSelected(), (deleted) =>
onDeleteDialogClosed(deleted)
)
);
}
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
closeModal();
// refetch
result.refetch();
}
function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) {
if (valueIndex === undefined) {
updateFilter(
filter.removeCriterion(removedCriterion.criterionOption.type)
);
} else {
updateFilter(
filter.removeCustomFieldCriterion(
removedCriterion.criterionOption.type,
valueIndex
)
);
}
}
function onClearAllCriteria() {
updateFilter(filter.clearCriteria());
}
const filterListToolbarProps: IFilteredListToolbar = {
filter,
setFilter: updateFilter,
listSelect,
showEditFilter,
view: view,
operations: operations,
zoomable: zoomable,
onEdit: renderEditDialog ? onEdit : undefined,
onDelete: renderDeleteDialog ? onDelete : undefined,
};
return (
<div className="item-list-container">
{providedToolbar ? (
providedToolbar(filterListToolbarProps)
) : (
<FilteredListToolbar {...filterListToolbarProps} />
)}
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={onRemoveCriterion}
onRemoveAll={() => onClearAllCriteria()}
/>
{modal}
<PagedList
result={result}
cachedResult={cachedResult}
filter={filter}
totalCount={totalCount}
onChangePage={onChangePage}
metadataByline={metadataByline}
>
{renderContent(
result,
// #4780 - use effectiveFilter to ensure filterHook is applied
effectiveFilter,
selectedIds,
onSelectChange,
onChangePage,
pages
)}
</PagedList>
</div>
);
};
interface IItemListContextProps<
T extends QueryResult,
E extends IHasID,
M = unknown
> {
filterMode: GQL.FilterMode;
defaultSort?: string;
defaultFilter?: ListFilterModel;
useResult: (filter: ListFilterModel) => T;
useMetadataInfo?: (filter: ListFilterModel) => M;
getCount: (data: T) => number;
getItems: (data: T) => E[];
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
alterQuery?: boolean;
selectable?: boolean;
}
// Provides the contexts for the ItemList component. Includes functionality to scroll
// to top on page change.
export const ItemListContext = <
T extends QueryResult,
E extends IHasID,
M = unknown
>(
props: PropsWithChildren<IItemListContextProps<T, E, M>>
) => {
const {
filterMode,
defaultSort,
defaultFilter: providedDefaultFilter,
useResult,
useMetadataInfo,
getCount,
getItems,
view,
filterHook,
alterQuery = true,
selectable,
children,
} = props;
const { configuration: config } = useConfigurationContext();
const emptyFilter = useMemo(
() =>
providedDefaultFilter?.clone() ??
new ListFilterModel(filterMode, config, {
defaultSortBy: defaultSort,
}),
[config, filterMode, defaultSort, providedDefaultFilter]
);
const [filter, setFilterState] = useState<ListFilterModel>(
() =>
new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })
);
const { defaultFilter } = useDefaultFilter(emptyFilter, view);
return (
<FilterContext filter={filter} setFilter={setFilterState}>
<SetFilterURL defaultFilter={defaultFilter} setURL={alterQuery}>
<QueryResultContext
filterHook={filterHook}
useResult={useResult}
useMetadataInfo={useMetadataInfo}
getCount={getCount}
getItems={getItems}
>
{({ items }) => (
<ListContext selectable={selectable} items={items}>
{children}
</ListContext>
)}
</QueryResultContext>
</SetFilterURL>
</FilterContext>
);
};
export const showWhenSelected = <T extends QueryResult>(
result: T,
filter: ListFilterModel,

View File

@@ -470,6 +470,10 @@ input[type="range"].zoom-slider {
line-height: 16px;
padding: 0;
}
.tag-item.unsupported {
background-color: $warning;
}
}
.filter-button {

View File

@@ -509,23 +509,27 @@ export function useCachedQueryResult<T extends QueryResult>(
export interface IQueryResultHook<
T extends QueryResult,
E extends IHasID = IHasID
E extends IHasID = IHasID,
M = unknown
> {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
useResult: (filter: ListFilterModel) => T;
useMetadataInfo?: (filter: ListFilterModel) => M;
getCount: (data: T) => number;
getItems: (data: T) => E[];
}
export function useQueryResult<
T extends QueryResult,
E extends IHasID = IHasID
E extends IHasID = IHasID,
M = unknown
>(
props: IQueryResultHook<T, E> & {
props: IQueryResultHook<T, E, M> & {
filter: ListFilterModel;
}
) {
const { filter, filterHook, useResult, getItems, getCount } = props;
const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } =
props;
const effectiveFilter = useMemo(() => {
if (filterHook) {
@@ -534,7 +538,14 @@ export function useQueryResult<
return filter;
}, [filter, filterHook]);
// metadata filter is the effective filter with the sort, page size and page number removed
const metadataFilter = useMemo(
() => effectiveFilter.metadataInfo(),
[effectiveFilter]
);
const result = useResult(effectiveFilter);
const metadataInfo = useMetadataInfo?.(metadataFilter);
// use cached query result for pagination and metadata rendering
const cachedResult = useCachedQueryResult(effectiveFilter, result);
@@ -549,6 +560,7 @@ export function useQueryResult<
return {
effectiveFilter,
metadataInfo,
result,
cachedResult,
items,

View File

@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
@@ -14,7 +14,7 @@ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerImages}

View File

@@ -423,7 +423,7 @@ export const FilteredPerformerList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@@ -454,7 +454,7 @@ export const FilteredPerformerList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindPerformers } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { PerformerCard } from "./PerformerCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,40 +15,29 @@ export const PerformerRecommendationRow: React.FC<IProps> = PatchComponent(
"PerformerRecommendationRow",
(props) => {
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findPerformers.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="performer-recommendations"
header={props.header}
link={
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/performers?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -412,7 +412,7 @@ export const FilteredSceneList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,

View File

@@ -1,7 +1,7 @@
import cloneDeep from "lodash-es/cloneDeep";
import React from "react";
import React, { useCallback, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import {
@@ -9,7 +9,7 @@ import {
useFindSceneMarkers,
} from "src/core/StashService";
import NavUtils from "src/utils/navigation";
import { ItemList, ItemListContext } from "../List/ItemList";
import { useFilteredItemList } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
@@ -17,17 +17,179 @@ import { View } from "../List/views";
import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid";
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog";
import { PatchComponent } from "src/patch";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import {
FilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import { useZoomKeybinds } from "../List/ZoomSlider";
import {
IListFilterOperation,
ListOperations,
} from "../List/ListOperationButtons";
import cx from "classnames";
import { FilterTags } from "../List/FilterTags";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { LoadedContent } from "../List/PagedList";
import useFocus from "src/utils/focus";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import { Button } from "react-bootstrap";
function getItems(result: GQL.FindSceneMarkersQueryResult) {
return result?.data?.findSceneMarkers?.scene_markers ?? [];
const SceneMarkerList: React.FC<{
markers: GQL.SceneMarkerDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}> = PatchComponent(
"SceneMarkerList",
({ markers, filter, selectedIds, onSelectChange }) => {
if (markers.length === 0) {
return null;
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<MarkerWallPanel
markers={markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneMarkerCardGrid
markers={markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
return null;
}
);
function usePlayRandom(filter: ListFilterModel, count: number) {
const history = useHistory();
const playRandom = useCallback(async () => {
// query for a random scene
if (count === 0) {
return;
}
const pages = Math.ceil(count / filter.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1;
const indexMax = Math.min(filter.itemsPerPage, count);
const index = Math.floor(Math.random() * indexMax);
const filterCopy = cloneDeep(filter);
filterCopy.currentPage = page;
filterCopy.sortBy = "random";
const queryResults = await queryFindSceneMarkers(filterCopy);
const marker = queryResults.data.findSceneMarkers.scene_markers[index];
if (marker) {
// navigate to the scene player page
const url = NavUtils.makeSceneMarkerUrl(marker);
history.push(url);
}
}, [filter, count, history]);
return playRandom;
}
function getCount(result: GQL.FindSceneMarkersQueryResult) {
return result?.data?.findSceneMarkers?.count ?? 0;
function useAddKeybinds(filter: ListFilterModel, count: number) {
const playRandom = usePlayRandom(filter, count);
useEffect(() => {
Mousetrap.bind("p r", () => {
playRandom();
});
return () => {
Mousetrap.unbind("p r");
};
}, [playRandom]);
}
const ScenesFilterSidebarSections = PatchContainerComponent(
"FilteredSceneMarkerList.SidebarSections"
);
const SidebarContent: React.FC<{
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
count?: number;
focus?: ReturnType<typeof useFocus>;
}> = ({
filter,
setFilter,
filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<ScenesFilterSidebarSections>
<SidebarPerformersFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
</ScenesFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
interface ISceneMarkerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
@@ -36,132 +198,274 @@ interface ISceneMarkerList {
extraOperations?: IItemListOperation<GQL.FindSceneMarkersQueryResult>[];
}
export const SceneMarkerList: React.FC<ISceneMarkerList> = PatchComponent(
"SceneMarkerList",
({ filterHook, view, alterQuery, extraOperations = [] }) => {
export const FilteredSceneMarkerList = PatchComponent(
"FilteredSceneMarkerList",
(props: ISceneMarkerList) => {
const intl = useIntl();
const history = useHistory();
const filterMode = GQL.FilterMode.SceneMarkers;
const searchFocus = useFocus();
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,
},
];
const {
filterHook,
defaultSort,
view,
alterQuery,
extraOperations = [],
} = props;
function addKeybinds(
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
// States
const {
showSidebar,
setShowSidebar,
loading: sidebarStateLoading,
sectionOpen,
setSectionOpen,
} = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.SceneMarkers,
defaultSort,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindSceneMarkers,
getCount: (r) => r.data?.findSceneMarkers.count ?? 0,
getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [],
filterHook,
},
});
const { filter, setFilter } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(filter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const onEdit = useCallback(() => {
showModal(
<EditSceneMarkersDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}, [showModal, selectedItems, onCloseEditDelete]);
const onDelete = useCallback(() => {
showModal(
<DeleteSceneMarkersDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}, [showModal, selectedItems, onCloseEditDelete]);
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
onEdit?.();
}
});
Mousetrap.bind("d d", () => {
if (hasSelection) {
onDelete?.();
}
});
return () => {
Mousetrap.unbind("p r");
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
}
}, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
async function playRandom(
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
// query for a random scene
if (result.data?.findSceneMarkers) {
const { count } = result.data.findSceneMarkers;
useZoomKeybinds({
zoomIndex: filter.zoomIndex,
onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
});
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindSceneMarkers(filterCopy);
if (singleResult.data.findSceneMarkers.scene_markers.length === 1) {
// navigate to the scene player page
const url = NavUtils.makeSceneMarkerUrl(
singleResult.data.findSceneMarkers.scene_markers[0]
);
history.push(url);
}
}
}
const playRandom = usePlayRandom(effectiveFilter, totalCount);
function renderContent(
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
if (!result.data?.findSceneMarkers) return;
const convertedExtraOperations: IListFilterOperation[] =
extraOperations.map((o) => ({
...o,
isDisplayed: o.isDisplayed
? () => o.isDisplayed!(result, filter, selectedIds)
: undefined,
onClick: () => {
o.onClick(result, filter, selectedIds);
},
}));
if (filter.displayMode === DisplayMode.Wall) {
return (
<MarkerWallPanel
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
const otherOperations = [
...convertedExtraOperations,
{
text: intl.formatMessage({ id: "actions.select_all" }),
onClick: () => onSelectAll(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.select_none" }),
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,
isDisplayed: () => totalCount > 1,
},
// {
// text: `${intl.formatMessage({ id: "actions.generate" })}…`,
// onClick: () =>
// showModal(
// <GenerateDialog
// type="scene"
// selectedIds={Array.from(selectedIds.values())}
// onClose={() => closeModal()}
// />
// ),
// isDisplayed: () => hasSelection,
// },
];
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneMarkerCardGrid
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
}
// render
if (sidebarStateLoading) return null;
function renderEditDialog(
selectedMarkers: GQL.SceneMarkerDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<EditSceneMarkersDialog selected={selectedMarkers} onClose={onClose} />
);
}
function renderDeleteDialog(
selectedSceneMarkers: GQL.SceneMarkerDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteSceneMarkersDialog
selected={selectedSceneMarkers}
onClose={onClose}
/>
);
}
const operations = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
operationsMenuClassName="scene-marker-list-operations-dropdown"
/>
);
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindSceneMarkers}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
<div
className={cx("item-list-container scene-list", {
"hide-sidebar": !showSidebar,
})}
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
{modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
zoomable
/>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
/>
</div>
<LoadedContent loading={result.loading} error={result.error}>
<SceneMarkerList
filter={effectiveFilter}
markers={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
</LoadedContent>
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
/>
</div>
</div>
)}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);
export default SceneMarkerList;
export default FilteredSceneMarkerList;

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindSceneMarkers } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { SceneMarkerCard } from "./SceneMarkerCard";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,47 +15,34 @@ export const SceneMarkerRecommendationRow: React.FC<IProps> = PatchComponent(
"SceneMarkerRecommendationRow",
(props) => {
const result = useFindSceneMarkers(props.filter);
const cardCount = result.data?.findSceneMarkers.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findSceneMarkers.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="scene-marker-recommendations"
header={props.header}
link={
<Link to={`/scenes/markers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/scenes/markers?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-marker-skeleton skeleton-card"
></div>
))
: result.data?.findSceneMarkers.scene_markers.map(
(marker, index) => (
<SceneMarkerCard
key={marker.id}
marker={marker}
index={index}
zoomIndex={1}
/>
)
)}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-marker-skeleton skeleton-card"
></div>
))
: result.data?.findSceneMarkers.scene_markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
marker={marker}
index={index}
zoomIndex={1}
/>
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -1,14 +1,10 @@
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { useFindScenes } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { SceneCard } from "./SceneCard";
import { SceneQueue } from "src/models/sceneQueue";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -20,50 +16,36 @@ export const SceneRecommendationRow: React.FC<IProps> = PatchComponent(
"SceneRecommendationRow",
(props) => {
const result = useFindScenes(props.filter);
const cardCount = result.data?.findScenes.count;
const count = result.data?.findScenes.count ?? 0;
const queue = useMemo(() => {
return SceneQueue.fromListFilterModel(props.filter);
}, [props.filter]);
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
<FilteredRecommendationRow
className="scene-recommendations"
header={props.header}
link={
<Link to={`/scenes?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/scenes?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-skeleton skeleton-card"
></div>
))
: result.data?.findScenes.scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
queue={queue}
index={index}
zoomIndex={1}
/>
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="scene-skeleton skeleton-card"></div>
))
: result.data?.findScenes.scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
queue={queue}
index={index}
zoomIndex={1}
/>
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -1,6 +1,8 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Overlay, Popover, OverlayProps } from "react-bootstrap";
import { PatchComponent } from "src/patch";
import { Icon } from "./Icon";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
interface IHoverPopover {
enterDelay?: number;
@@ -85,3 +87,20 @@ export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
);
}
);
// convenience component to set the padding on popover content
export const PopoverCard: React.FC<{ className?: string }> = ({
className,
children,
}) => {
return <div className={`popover-card ${className}`}>{children}</div>;
};
export const WarningHoverPopover: React.FC<IHoverPopover> = PatchComponent(
"WarningHoverPopover",
({ children, ...props }) => (
<HoverPopover {...props} className="warning-hover-popover">
<Icon icon={faExclamationTriangle} />
</HoverPopover>
)
);

View File

@@ -233,6 +233,19 @@ button.collapse-button {
.hover-popover-content {
max-width: 32rem;
text-align: center;
.popover-card {
padding: 0.5rem;
}
}
.warning-hover-popover {
display: inline-flex;
margin: 0 0.25rem;
.fa-icon {
color: $warning;
}
}
.ErrorMessage-container {

View File

@@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useStudioFilterHook } from "src/core/studios";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { View } from "src/components/List/views";
interface IStudioImagesPanel {
@@ -17,7 +17,7 @@ export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.StudioImages}

View File

@@ -251,7 +251,7 @@ export const FilteredStudioList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@@ -282,7 +282,7 @@ export const FilteredStudioList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindStudios } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { StudioCard } from "./StudioCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,40 +15,29 @@ export const StudioRecommendationRow: React.FC<IProps> = PatchComponent(
"StudioRecommendationRow",
(props) => {
const result = useFindStudios(props.filter);
const cardCount = result.data?.findStudios.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findStudios.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="studio-recommendations"
header={props.header}
link={
<Link to={`/studios?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/studios?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="studio-skeleton skeleton-card"
></div>
))
: result.data?.findStudios.studios.map((s) => (
<StudioCard key={s.id} studio={s} hideParent={true} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="studio-skeleton skeleton-card"
></div>
))
: result.data?.findStudios.studios.map((s) => (
<StudioCard key={s.id} studio={s} hideParent={true} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { View } from "src/components/List/views";
interface ITagImagesPanel {
@@ -17,7 +17,7 @@ export const TagImagesPanel: React.FC<ITagImagesPanel> = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.TagImages}

View File

@@ -5,7 +5,7 @@ import {
TagsCriterion,
TagsCriterionOption,
} from "src/models/list-filter/criteria/tags";
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
import { FilteredSceneMarkerList } from "src/components/Scenes/SceneMarkerList";
import { View } from "src/components/List/views";
function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) {
@@ -60,7 +60,7 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
const filterHook = useFilterHook(tag, showSubTagContent);
return (
<SceneMarkerList
<FilteredSceneMarkerList
filterHook={filterHook}
alterQuery={active}
view={View.TagMarkers}

View File

@@ -1,9 +1,9 @@
import React, { useState } from "react";
import React, { useCallback, useEffect } from "react";
import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { useFilteredItemList } from "../List/ItemList";
import { Button } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
@@ -11,33 +11,269 @@ import {
queryFindTagsForList,
mutateMetadataAutoTag,
useFindTagsForList,
useTagDestroy,
useTagsDestroy,
} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import NavUtils from "src/utils/navigation";
import { Icon } from "../Shared/Icon";
import { ModalComponent } from "../Shared/Modal";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { tagRelationHook } from "../../core/tags";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { TagMergeModal } from "./TagMergeDialog";
import { Tag } from "./TagSelect";
import { TagCardGrid } from "./TagCardGrid";
import { EditTagsDialog } from "./EditTagsDialog";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { PatchComponent } from "src/patch";
import {
FilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { TagTagger } from "../Tagger/tags/TagTagger";
import useFocus from "src/utils/focus";
import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import { ListOperations } from "../List/ListOperationButtons";
import cx from "classnames";
import { FilterTags } from "../List/FilterTags";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { LoadedContent } from "../List/PagedList";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite";
function getItems(result: GQL.FindTagsForListQueryResult) {
return result?.data?.findTags?.tags ?? [];
const TagList: React.FC<{
tags: GQL.TagListDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onDelete: (tag: GQL.TagListDataFragment) => void;
onAutoTag: (tag: GQL.TagListDataFragment) => void;
}> = PatchComponent(
"TagList",
({ tags, filter, selectedIds, onSelectChange, onDelete, onAutoTag }) => {
if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) {
return null;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<TagCardGrid
tags={tags}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
const tagElements = tags.map((tag) => {
return (
<div key={tag.id} className="tag-list-row row">
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
<div className="ml-auto">
<Button
variant="secondary"
className="tag-list-button"
onClick={() => onAutoTag(tag)}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagScenesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.scenes"
values={{
count: tag.scene_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagImagesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.images"
values={{
count: tag.image_count ?? 0,
}}
/>
: <FormattedNumber value={tag.image_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagGalleriesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.galleries"
values={{
count: tag.gallery_count ?? 0,
}}
/>
: <FormattedNumber value={tag.gallery_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.markers"
values={{
count: tag.scene_marker_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link>
</Button>
<span className="tag-list-count">
<FormattedMessage id="total" />:{" "}
<FormattedNumber
value={
(tag.scene_count || 0) +
(tag.scene_marker_count || 0) +
(tag.image_count || 0) +
(tag.gallery_count || 0)
}
/>
</span>
<Button variant="danger" onClick={() => onDelete(tag)}>
<Icon icon={faTrashAlt} color="danger" />
</Button>
</div>
</div>
);
});
return <div className="col col-sm-8 m-auto">{tagElements}</div>;
}
if (filter.displayMode === DisplayMode.Tagger) {
return <TagTagger tags={tags} />;
}
return null;
}
);
const TagFilterSidebarSections = PatchContainerComponent(
"FilteredTagList.SidebarSections"
);
const SidebarContent: React.FC<{
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
count?: number;
focus?: ReturnType<typeof useFocus>;
}> = ({
filter,
setFilter,
// filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<TagFilterSidebarSections>
{/* <SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/> */}
<SidebarBooleanFilter
title={<FormattedMessage id="favourite" />}
filter={filter}
setFilter={setFilter}
option={FavoriteTagCriterionOption}
sectionID="favourite"
/>
</TagFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
function useViewRandom(filter: ListFilterModel, count: number) {
const history = useHistory();
const viewRandom = useCallback(async () => {
// query for a random tag
if (count === 0) {
return;
}
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindTagsForList(filterCopy);
if (singleResult.data.findTags.tags.length === 1) {
const { id } = singleResult.data.findTags.tags[0];
// navigate to the tag page
history.push(`/tags/${id}`);
}
}, [history, filter, count]);
return viewRandom;
}
function getCount(result: GQL.FindTagsForListQueryResult) {
return result?.data?.findTags?.count ?? 0;
function useAddKeybinds(filter: ListFilterModel, count: number) {
const viewRandom = useViewRandom(filter, count);
useEffect(() => {
Mousetrap.bind("p r", () => {
viewRandom();
});
return () => {
Mousetrap.unbind("p r");
};
}, [viewRandom]);
}
interface ITagList {
@@ -46,105 +282,155 @@ interface ITagList {
extraOperations?: IItemListOperation<GQL.FindTagsForListQueryResult>[];
}
export const TagList: React.FC<ITagList> = PatchComponent(
"TagList",
({ filterHook, alterQuery, extraOperations = [] }) => {
const Toast = useToast();
const [deletingTag, setDeletingTag] =
useState<Partial<GQL.TagListDataFragment> | null>(null);
const filterMode = GQL.FilterMode.Tags;
const view = View.Tags;
function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) {
tagInput.id = deletingTag.id;
}
return tagInput as GQL.TagDestroyInput;
}
const [deleteTag] = useTagDestroy(getDeleteTagInput());
export const FilteredTagList = PatchComponent(
"FilteredTagList",
(props: ITagList) => {
const intl = useIntl();
const history = useHistory();
const [mergeTags, setMergeTags] = useState<Tag[] | undefined>(undefined);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const Toast = useToast();
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: merge,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
const searchFocus = useFocus();
function addKeybinds(
result: GQL.FindTagsForListQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
const { filterHook, alterQuery, extraOperations = [] } = props;
const view = View.Tags;
// States
const {
showSidebar,
setShowSidebar,
sectionOpen,
setSectionOpen,
loading: sidebarStateLoading,
} = useSidebarState(view);
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Tags,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindTagsForList,
getCount: (r) => r.data?.findTags.count ?? 0,
getItems: (r) => r.data?.findTags.tags ?? [],
filterHook,
},
});
const { filter, setFilter } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
});
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
onEdit?.();
}
});
Mousetrap.bind("d d", () => {
if (hasSelection) {
onDelete?.();
}
});
return () => {
Mousetrap.unbind("p r");
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
studios: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
async function viewRandom(
result: GQL.FindTagsForListQueryResult,
filter: ListFilterModel
) {
// query for a random tag
if (result.data?.findTags) {
const { count } = result.data.findTags;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindTagsForList(filterCopy);
if (singleResult.data.findTags.tags.length === 1) {
const { id } = singleResult.data.findTags.tags[0];
// navigate to the tag page
history.push(`/tags/${id}`);
}
}
function onEdit() {
showModal(
<EditTagsDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}
async function merge(
result: GQL.FindTagsForListQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const selected =
result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? [];
setMergeTags(selected);
function onDelete(tag?: GQL.TagListDataFragment) {
const itemsToDelete = tag ? [tag] : selectedItems;
showModal(
<DeleteEntityDialog
selected={itemsToDelete}
onClose={onCloseEditDelete}
singularEntity={intl.formatMessage({ id: "tag" })}
pluralEntity={intl.formatMessage({ id: "tags" })}
destroyMutation={useTagsDestroy}
onDeleted={() => {
itemsToDelete.forEach((t) =>
tagRelationHook(
t,
{ parents: t.parents ?? [], children: t.children ?? [] },
{ parents: [], children: [] }
)
);
}}
/>
);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
function onMerge() {
showModal(
<TagMergeModal
tags={selectedItems}
onClose={(mergedId?: string) => {
onCloseEditDelete();
if (mergedId) {
history.push(`/tags/${mergedId}`);
}
}}
show
/>
);
}
async function onAutoTag(tag: GQL.TagListDataFragment) {
@@ -157,269 +443,151 @@ export const TagList: React.FC<ITagList> = PatchComponent(
}
}
async function onDelete() {
try {
const oldRelations = {
parents: deletingTag?.parents ?? [],
children: deletingTag?.children ?? [],
};
await deleteTag();
tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, {
parents: [],
children: [],
});
Toast.success(
intl.formatMessage(
{ id: "toast.delete_past_tense" },
{
count: 1,
singularEntity: intl.formatMessage({ id: "tag" }),
pluralEntity: intl.formatMessage({ id: "tags" }),
}
)
);
setDeletingTag(null);
} catch (e) {
Toast.error(e);
}
}
const convertedExtraOperations = extraOperations.map((op) => ({
text: op.text,
onClick: () => op.onClick(result, filter, selectedIds),
isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,
}));
function renderContent(
result: GQL.FindTagsForListQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function renderMergeDialog() {
if (mergeTags) {
return (
<TagMergeModal
tags={mergeTags}
onClose={(mergedId?: string) => {
setMergeTags(undefined);
if (mergedId) {
history.push(`/tags/${mergedId}`);
}
}}
show
/>
);
}
}
const otherOperations = [
...convertedExtraOperations,
{
text: intl.formatMessage({ id: "actions.select_all" }),
onClick: () => onSelectAll(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.select_none" }),
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: () => onMerge(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: () => onExport(true),
},
];
function maybeRenderExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
tags: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
// render
if (sidebarStateLoading) return null;
function renderTags() {
if (!result.data?.findTags) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<TagCardGrid
tags={result.data.findTags.tags}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
const deleteAlert = (
<ModalComponent
onHide={() => {}}
show={!!deletingTag}
icon={faTrashAlt}
accept={{
onClick: onDelete,
variant: "danger",
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ onClick: () => setDeletingTag(null) }}
>
<span>
<FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: deletingTag && deletingTag.name }}
/>
</span>
</ModalComponent>
);
const tagElements = result.data.findTags.tags.map((tag) => {
return (
<div key={tag.id} className="tag-list-row row">
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
<div className="ml-auto">
<Button
variant="secondary"
className="tag-list-button"
onClick={() => onAutoTag(tag)}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagScenesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.scenes"
values={{
count: tag.scene_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagImagesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.images"
values={{
count: tag.image_count ?? 0,
}}
/>
: <FormattedNumber value={tag.image_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagGalleriesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.galleries"
values={{
count: tag.gallery_count ?? 0,
}}
/>
: <FormattedNumber value={tag.gallery_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.markers"
values={{
count: tag.scene_marker_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link>
</Button>
<span className="tag-list-count">
<FormattedMessage id="total" />:{" "}
<FormattedNumber
value={
(tag.scene_count || 0) +
(tag.scene_marker_count || 0) +
(tag.image_count || 0) +
(tag.gallery_count || 0)
}
/>
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon={faTrashAlt} color="danger" />
</Button>
</div>
</div>
);
});
return (
<div className="col col-sm-8 m-auto">
{tagElements}
{deleteAlert}
</div>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
if (filter.displayMode === DisplayMode.Tagger) {
return <TagTagger tags={result.data.findTags.tags} />;
}
}
return (
<>
{renderMergeDialog()}
{maybeRenderExportDialog()}
{renderTags()}
</>
);
}
function renderEditDialog(
selectedTags: GQL.TagListDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <EditTagsDialog selected={selectedTags} onClose={onClose} />;
}
function renderDeleteDialog(
selectedTags: GQL.TagListDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedTags}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "tag" })}
pluralEntity={intl.formatMessage({ id: "tags" })}
destroyMutation={useTagsDestroy}
onDeleted={() => {
selectedTags.forEach((t) =>
tagRelationHook(
t,
{ parents: t.parents ?? [], children: t.children ?? [] },
{ parents: [], children: [] }
)
);
}}
/>
);
}
const operations = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
operationsMenuClassName="tag-list-operations-dropdown"
/>
);
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindTagsForList}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
<div
className={cx("item-list-container tag-list", {
"hide-sidebar": !showSidebar,
})}
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
{modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
zoomable
/>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
/>
</div>
<LoadedContent loading={result.loading} error={result.error}>
<TagList
filter={effectiveFilter}
tags={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
onDelete={(tag) => onDelete(tag)}
onAutoTag={(tag) => onAutoTag(tag)}
/>
</LoadedContent>
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
/>
</div>
</div>
)}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);

View File

@@ -1,13 +1,9 @@
import React from "react";
import { Link } from "react-router-dom";
import { useFindTags } from "src/core/StashService";
import Slider from "@ant-design/react-slick";
import { TagCard } from "./TagCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
interface IProps {
isTouch: boolean;
@@ -19,37 +15,26 @@ export const TagRecommendationRow: React.FC<IProps> = PatchComponent(
"TagRecommendationRow",
(props) => {
const result = useFindTags(props.filter);
const cardCount = result.data?.findTags.count;
if (!result.loading && !cardCount) {
return null;
}
const count = result.data?.findTags.count ?? 0;
return (
<RecommendationRow
<FilteredRecommendationRow
className="tag-recommendations"
header={props.header}
link={
<Link to={`/tags?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
heading={props.header}
url={`/tags?${props.filter.makeQueryParameters()}`}
count={count}
loading={result.loading}
isTouch={props.isTouch}
filter={props.filter}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
))
: result.data?.findTags.tags.map((p) => (
<TagCard key={p.id} tag={p} zoomIndex={0} />
))}
</Slider>
</RecommendationRow>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
))
: result.data?.findTags.tags.map((p) => (
<TagCard key={p.id} tag={p} zoomIndex={0} />
))}
</FilteredRecommendationRow>
);
}
);

View File

@@ -4,10 +4,10 @@ import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Tag from "./TagDetails/Tag";
import TagCreate from "./TagDetails/TagCreate";
import { TagList } from "./TagList";
import { FilteredTagList } from "./TagList";
const Tags: React.FC = () => {
return <TagList />;
return <FilteredTagList />;
};
const TagRoutes: React.FC = () => {

View File

@@ -230,7 +230,13 @@ Returns `void`.
- `ExternalLinkButtons`
- `ExternalLinksButton`
- `FilteredGalleryList`
- `FilteredGroupList`
- `FilteredImageList`
- `FilteredPerformerList`
- `FilteredSceneList`
- `FilteredSceneMarkerList`
- `FilteredStudioList`
- `FilteredTagList`
- `FolderSelect`
- `FrontPage`
- `GalleryCard`
@@ -248,6 +254,7 @@ Returns `void`.
- `GroupCard`
- `GroupCardGrid`
- `GroupIDSelect`
- `GroupList`
- `GroupRecommendationRow`
- `GroupSelect`
- `GroupSelect.sort`
@@ -262,6 +269,7 @@ Returns `void`.
- `ImageDetailPanel`
- `ImageGridCard`
- `ImageInput`
- `ImageList`
- `ImageRecommendationRow`
- `LightboxLink`
- `LoadingIndicator`
@@ -286,6 +294,7 @@ Returns `void`.
- `PerformerHeaderImage`
- `PerformerIDSelect`
- `PerformerImagesPanel`
- `PerformerList`
- `PerformerPage`
- `PerformerRecommendationRow`
- `PerformerScenesPanel`
@@ -310,6 +319,7 @@ Returns `void`.
- `SceneMarkerCard.Image`
- `SceneMarkerCard.Popovers`
- `SceneMarkerCardsGrid`
- `SceneMarkerList`
- `SceneMarkerRecommendationRow`
- `SceneList`
- `ScenePage`
@@ -329,6 +339,7 @@ Returns `void`.
- `StudioCardGrid`
- `StudioDetailsPanel`
- `StudioIDSelect`
- `StudioList`
- `StudioRecommendationRow`
- `StudioSelect`
- `StudioSelect.sort`
@@ -343,6 +354,7 @@ Returns `void`.
- `TagCardGrid`
- `TagIDSelect`
- `TagLink`
- `TagList`
- `TagRecommendationRow`
- `TagSelect`
- `TagSelect.sort`

View File

@@ -441,7 +441,7 @@
"heading": "Scrapers path"
},
"scraping": "Scraping",
"sprite_generation_head": "Sprite generation",
"sprite_generation_head": "Sprite Generation",
"sprite_interval_desc": "Time between each generated sprite in seconds.",
"sprite_interval_head": "Sprite interval",
"sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.",
@@ -669,7 +669,7 @@
},
"custom_locales": {
"description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.",
"heading": "Custom Localisation",
"heading": "Custom localisation",
"option_label": "Custom localisation enabled"
},
"custom_title": {
@@ -918,6 +918,7 @@
"criterion": {
"greater_than": "Greater than",
"less_than": "Less than",
"unsupported": "{type} (unsupported)",
"value": "Value"
},
"criterion_modifier": {
@@ -1656,6 +1657,7 @@
"twitter": "Twitter",
"type": "Type",
"unknown_date": "Unknown date",
"unsupported_criteria": "Unsupported criteria: {criteria}",
"updated_at": "Updated At",
"url": "URL",
"urls": "URLs",

View File

@@ -1221,3 +1221,54 @@ export class TimestampCriterion extends ModifierCriterion<ITimestampValue> {
return true;
}
}
export class UnsupportedCriterionOption extends StringCriterionOption {
constructor(type: string) {
super({
messageID: "unsupported_criterion",
type: type as CriterionType,
makeCriterion: () => new UnsupportedCriterion(this),
});
}
}
export class UnsupportedCriterion extends StringCriterion {
public getLabel(intl: IntlShape): string {
const modifierString = ModifierCriterion.getModifierLabel(
intl,
this.modifier
);
let valueString = "";
if (
this.modifier !== CriterionModifier.IsNull &&
this.modifier !== CriterionModifier.NotNull
) {
valueString = this.getLabelValue(intl);
}
return intl.formatMessage(
{ id: "criterion_modifier.format_string" },
{
criterion: intl.formatMessage(
{ id: "criterion.unsupported" },
{ type: this.criterionOption.type }
),
modifierString,
valueString,
}
);
}
public applyToCriterionInput(): void {
// do nothing
}
public applyToSavedCriterion(): void {
// do nothing
}
public setFromSavedCriterion(): void {
// do nothing
}
}

View File

@@ -26,10 +26,13 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
"is_missing",
[
"title",
"cover",
"code",
"details",
"director",
"url",
"date",
"rating",
"cover",
"galleries",
"studio",
"group",
@@ -42,7 +45,19 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
export const ImageIsMissingCriterionOption = new IsMissingCriterionOption(
"isMissing",
"is_missing",
["title", "galleries", "studio", "performers", "tags"]
[
"title",
"details",
"photographer",
"url",
"date",
"code",
"rating",
"galleries",
"studio",
"performers",
"tags",
]
);
export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
@@ -58,14 +73,21 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
"weight",
"measurements",
"fake_tits",
"penis_length",
"circumcised",
"career_start",
"career_end",
"tattoos",
"piercings",
"aliases",
"gender",
"birthdate",
"death_date",
"disambiguation",
"tags",
"image",
"details",
"rating",
"stash_id",
]
);
@@ -73,23 +95,49 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
export const GalleryIsMissingCriterionOption = new IsMissingCriterionOption(
"isMissing",
"is_missing",
["title", "details", "url", "date", "studio", "performers", "tags", "scenes"]
[
"title",
"code",
"details",
"photographer",
"url",
"date",
"rating",
"cover",
"studio",
"performers",
"tags",
"scenes",
]
);
export const TagIsMissingCriterionOption = new IsMissingCriterionOption(
"isMissing",
"is_missing",
["image"]
["image", "aliases", "description", "stash_id"]
);
export const StudioIsMissingCriterionOption = new IsMissingCriterionOption(
"isMissing",
"is_missing",
["image", "stash_id", "details"]
["image", "stash_id", "details", "url", "aliases", "tags", "rating"]
);
export const GroupIsMissingCriterionOption = new IsMissingCriterionOption(
"isMissing",
"is_missing",
["front_image", "back_image", "scenes"]
[
"aliases",
"description",
"director",
"date",
"url",
"rating",
"studio",
"performers",
"tags",
"front_image",
"back_image",
"scenes",
]
);

View File

@@ -5,7 +5,7 @@ import {
SavedFilterDataFragment,
SortDirectionEnum,
} from "src/core/generated-graphql";
import { Criterion } from "./criteria/criterion";
import { Criterion, UnsupportedCriterionOption } from "./criteria/criterion";
import { getFilterOptions } from "./factory";
import { CriterionType, DisplayMode, SavedUIOptions } from "./types";
import { ListFilterOptions } from "./filter-options";
@@ -437,7 +437,7 @@ export class ListFilterModel {
const option = criterionOptions.find((o) => o.type === type);
if (!option) {
throw new Error(`Unknown criterion parameter name: ${type}`);
return new UnsupportedCriterionOption(type).makeCriterion(this.config);
}
return option.makeCriterion(this.config);

View File

@@ -44,6 +44,9 @@ const displayModeOptions = [
DisplayMode.Wall,
];
export const PerformerAgeCriterionOption =
createMandatoryNumberCriterionOption("performer_age");
const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
@@ -61,7 +64,7 @@ const criterionOptions = [
PerformerTagsCriterionOption,
PerformersCriterionOption,
createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"),
PerformerAgeCriterionOption,
PerformerFavoriteCriterionOption,
createMandatoryNumberCriterionOption("image_count"),
// StudioTagsCriterionOption,

View File

@@ -16,7 +16,6 @@ import { OrientationCriterionOption } from "./criteria/orientation";
import { StudiosCriterionOption } from "./criteria/studios";
import {
PerformerTagsCriterionOption,
// StudioTagsCriterionOption,
TagsCriterionOption,
} from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
@@ -43,6 +42,10 @@ const sortByOptions = [
},
]);
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
export const PerformerAgeCriterionOption =
createMandatoryNumberCriterionOption("performer_age");
const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
@@ -65,7 +68,7 @@ const criterionOptions = [
PerformerTagsCriterionOption,
PerformersCriterionOption,
createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"),
PerformerAgeCriterionOption,
PerformerFavoriteCriterionOption,
// StudioTagsCriterionOption,
StudiosCriterionOption,

View File

@@ -667,7 +667,13 @@ declare namespace PluginApi {
ExternalLinkButtons: React.FC<any>;
ExternalLinksButton: React.FC<any>;
FilteredGalleryList: React.FC<any>;
FilteredGroupList: React.FC<any>;
FilteredImageList: React.FC<any>;
FilteredPerformerList: React.FC<any>;
FilteredSceneList: React.FC<any>;
FilteredSceneMarkerList: React.FC<any>;
FilteredStudioList: React.FC<any>;
FilteredTagList: React.FC<any>;
FolderSelect: React.FC<any>;
FrontPage: React.FC<any>;
GalleryCard: React.FC<any>;