From 47dcdd439cea335d80025c56e2ccc9f415a41a2c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:39:28 +1100 Subject: [PATCH 001/152] Backend support for gallery custom fields (#6592) --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/gallery.graphql | 7 + internal/api/loaders/dataloaders.go | 22 +- internal/api/resolver_model_gallery.go | 13 + internal/api/resolver_mutation_gallery.go | 17 +- internal/autotag/integration_test.go | 5 +- internal/manager/task_export.go | 7 + pkg/gallery/import.go | 20 +- pkg/gallery/scan.go | 7 +- pkg/image/scan.go | 15 +- pkg/models/gallery.go | 5 + pkg/models/jsonschema/gallery.go | 33 +-- pkg/models/mocks/GalleryReaderWriter.go | 74 +++++- pkg/models/model_gallery.go | 16 ++ pkg/models/repository_gallery.go | 7 +- pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 6 + pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery.go | 33 ++- pkg/sqlite/gallery_filter.go | 7 + pkg/sqlite/gallery_test.go | 248 +++++++++++++++++- .../81_gallery_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/tables.go | 1 + 24 files changed, 528 insertions(+), 50 deletions(-) create mode 100644 pkg/sqlite/migrations/81_gallery_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 075e40372..d683329b6 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -596,6 +596,8 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + + custom_fields: [CustomFieldCriterionInput!] } input TagFilterType { diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index f456157a7..e28c3802b 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -32,6 +32,7 @@ type Gallery { cover: Image paths: GalleryPathsType! # Resolver + custom_fields: Map! image(index: Int!): Image! } @@ -50,6 +51,8 @@ input GalleryCreateInput { studio_id: ID tag_ids: [ID!] performer_ids: [ID!] + + custom_fields: Map } input GalleryUpdateInput { @@ -71,6 +74,8 @@ input GalleryUpdateInput { performer_ids: [ID!] primary_file_id: ID + + custom_fields: CustomFieldsInput } input BulkGalleryUpdateInput { @@ -89,6 +94,8 @@ input BulkGalleryUpdateInput { studio_id: ID tag_ids: BulkUpdateIds performer_ids: BulkUpdateIds + + custom_fields: CustomFieldsInput } input GalleryDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 520714432..e7293ad1c 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -54,8 +54,9 @@ type Loaders struct { ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader - GalleryByID *GalleryLoader - ImageByID *ImageLoader + GalleryByID *GalleryLoader + GalleryCustomFields *CustomFieldsLoader + ImageByID *ImageLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -88,6 +89,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGalleries(ctx), }, + GalleryCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGalleryCustomFields(ctx), + }, ImageByID: &ImageLoader{ wait: wait, maxBatch: maxBatch, @@ -319,6 +325,18 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 9dc68b4c4..773a831d8 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -216,3 +216,16 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index return } + +func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index e7f853922..2cd80b1ff 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -42,7 +42,10 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat } // Populate a new gallery from the input - newGallery := models.NewGallery() + newGallery := models.CreateGalleryInput{ + Gallery: &models.Gallery{}, + } + *newGallery.Gallery = models.NewGallery() newGallery.Title = strings.TrimSpace(input.Title) newGallery.Code = translator.string(input.Code) @@ -81,10 +84,12 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } + newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields) + // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery - if err := qb.Create(ctx, &newGallery, nil); err != nil { + if err := qb.Create(ctx, &newGallery); err != nil { return err } @@ -241,6 +246,10 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + // gallery scene is set from the scene only gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) @@ -293,6 +302,10 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Gallery{} // Start the transaction and save the galleries diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 27cce014e..9745d623e 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -468,7 +468,10 @@ func makeGallery(expectedResult bool) *models.Gallery { } func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateGalleryInput{ + Gallery: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 5f2897670..30adf626b 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -779,6 +779,7 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC studioReader := r.Studio performerReader := r.Performer tagReader := r.Tag + galleryReader := r.Gallery galleryChapterReader := r.GalleryChapter for g := range jobChan { @@ -847,6 +848,12 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC newGalleryJSON.Tags = tag.GetNames(tags) + newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID) + if err != nil { + logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err) + continue + } + if t.includeDependencies { if g.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 22f3e6c44..e33297bdb 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -28,8 +28,9 @@ type Importer struct { Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum - ID int - gallery models.Gallery + ID int + gallery models.Gallery + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -51,6 +52,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -356,7 +359,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { for _, f := range i.gallery.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.gallery, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{ + Gallery: &i.gallery, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating gallery: %v", err) } @@ -368,7 +375,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { gallery := i.gallery gallery.ID = id - err := i.ReaderWriter.Update(ctx, &gallery) + err := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{ + Gallery: &gallery, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing gallery: %v", err) } diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 9d0313b17..2064355cd 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -17,7 +17,7 @@ type ScanCreatorUpdater interface { FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) - Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -80,7 +80,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("%s doesn't exist. Creating new gallery...", f.Base().Path) - if err := h.CreatorUpdater.Create(ctx, &newGallery, []models.FileID{baseFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{baseFile.ID}, + }); err != nil { return fmt.Errorf("creating new gallery: %w", err) } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index a6002057f..67f4b334c 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -35,7 +35,7 @@ type ScanCreatorUpdater interface { type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) - Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } @@ -252,9 +252,13 @@ func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f model newGallery := models.NewGallery() newGallery.FolderID = &folderID + input := models.CreateGalleryInput{ + Gallery: &newGallery, + } + logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) - if err := h.GalleryFinder.Create(ctx, &newGallery, nil); err != nil { + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } @@ -308,7 +312,12 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) - if err := h.GalleryFinder.Create(ctx, &newGallery, []models.FileID{zipFile.Base().ID}); err != nil { + input := models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{zipFile.Base().ID}, + } + + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index dfc776afe..8f335020a 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -67,6 +67,9 @@ type GalleryFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type GalleryUpdateInput struct { @@ -86,6 +89,8 @@ type GalleryUpdateInput struct { PerformerIds []string `json:"performer_ids"` PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` + // deprecated URL *string `json:"url"` } diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index 7323e37ba..5fb6e16ab 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -18,22 +18,23 @@ type GalleryChapter struct { } type Gallery struct { - ZipFiles []string `json:"zip_files,omitempty"` - FolderPath string `json:"folder_path,omitempty"` - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - Chapters []GalleryChapter `json:"chapters,omitempty"` - Studio string `json:"studio,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + ZipFiles []string `json:"zip_files,omitempty"` + FolderPath string `json:"folder_path,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + Chapters []GalleryChapter `json:"chapters,omitempty"` + Studio string `json:"studio,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f07f8a7d9..f20d9f76e 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -114,13 +114,13 @@ func (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models. return r0, r1 } -// Create provides a mock function with given fields: ctx, newGallery, fileIDs -func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newGallery, fileIDs) +// Create provides a mock function with given fields: ctx, newGallery +func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error { + ret := _m.Called(ctx, newGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery, []models.FileID) error); ok { - r0 = rf(ctx, newGallery, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok { + r0 = rf(ctx, newGallery) } else { r0 = ret.Error(0) } @@ -395,6 +395,52 @@ func (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -656,12 +702,26 @@ func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, cove return r0 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery -func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { +func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error { ret := _m.Called(ctx, updatedGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok { r0 = rf(ctx, updatedGallery) } else { r0 = ret.Error(0) diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 4b6a3183d..bbdba46a6 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -46,6 +46,20 @@ func NewGallery() Gallery { } } +type CreateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields CustomFieldsInput `json:"custom_fields"` +} + // GalleryPartial represents part of a Gallery object. It is used to update // the database entry. Only non-nil fields will be updated. type GalleryPartial struct { @@ -70,6 +84,8 @@ type GalleryPartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + + CustomFields CustomFieldsInput } func NewGalleryPartial() GalleryPartial { diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 0cfb9964f..b8f1452f3 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -37,12 +37,12 @@ type GalleryCounter interface { // GalleryCreator provides methods to create galleries. type GalleryCreator interface { - Create(ctx context.Context, newGallery *Gallery, fileIDs []FileID) error + Create(ctx context.Context, newGallery *CreateGalleryInput) error } // GalleryUpdater provides methods to update galleries. type GalleryUpdater interface { - Update(ctx context.Context, updatedGallery *Gallery) error + Update(ctx context.Context, updatedGallery *UpdateGalleryInput) error UpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error } @@ -70,6 +70,7 @@ type GalleryReader interface { PerformerIDLoader TagIDLoader FileLoader + CustomFieldsReader All(ctx context.Context) ([]*Gallery, error) } @@ -80,6 +81,8 @@ type GalleryWriter interface { GalleryUpdater GalleryDestroyer + CustomFieldsWriter + AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index e0a354980..6a5cd4da5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -522,6 +522,10 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), "gallery_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index a2c045851..ae4a276f7 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -240,3 +240,9 @@ func TestSceneSetCustomFields(t *testing.T) { testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) } + +func TestGallerySetCustomFields(t *testing.T) { + galleryIdx := galleryIdxWithScene + + testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 5b67e5602..d95832836 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 80 +var appSchemaVersion uint = 81 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 41729057b..305b1fe09 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -183,6 +183,8 @@ var ( ) type GalleryStore struct { + customFieldsStore + tableMgr *table fileStore *FileStore @@ -191,6 +193,10 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ + customFieldsStore: customFieldsStore{ + table: galleriesCustomFieldsTable, + fk: galleriesCustomFieldsTable.Col(galleryIDColumn), + }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -231,18 +237,18 @@ func (qb *GalleryStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error { +func (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error { var r galleryRow - r.fromGallery(*newObject) + r.fromGallery(*newObject.Gallery) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -269,19 +275,24 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Gallery = *updated return nil } -func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Gallery) error { +func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error { var r galleryRow - r.fromGallery(*updatedObject) + r.fromGallery(*updatedObject.Gallery) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -319,6 +330,10 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } @@ -364,6 +379,10 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f05ff7b81..f920e442a 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -105,6 +105,13 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + &customFieldsFilterHandler{ + table: galleriesCustomFieldsTable.GetTable(), + fkCol: galleryIDColumn, + c: filter.CustomFields, + idCol: "galleries.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_galleries.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 06d7daf17..4156f129c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -160,7 +160,10 @@ func Test_galleryQueryBuilder_Create(t *testing.T) { fileIDs = []models.FileID{s.Files.List()[0].Base().ID} } - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + if err := qb.Create(ctx, &models.CreateGalleryInput{ + Gallery: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -360,7 +363,9 @@ func Test_galleryQueryBuilder_Update(t *testing.T) { copy := *tt.updatedObject - if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + if err := qb.Update(ctx, &models.UpdateGalleryInput{ + Gallery: tt.updatedObject, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -3001,6 +3006,245 @@ func TestGallerySetAndResetCover(t *testing.T) { }) } +func TestGalleryQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not equals", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "includes", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "excludes", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{galleryIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "between", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not between", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + galleries, _, err := db.Gallery.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := galleriesToIDs(galleries) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql new file mode 100644 index 000000000..89a6e4c05 --- /dev/null +++ b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `gallery_custom_fields` ( + `gallery_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`gallery_id`, `field`), + foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE +); + +CREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91f9f127b..675b3f417 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1389,6 +1389,18 @@ func makeGallery(i int, includeScenes bool) *models.Gallery { return ret } +func getGalleryCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGalleryStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func createGalleries(ctx context.Context, n int) error { gqb := db.Gallery fqb := db.File @@ -1410,7 +1422,11 @@ func createGalleries(ctx context.Context, n int) error { const includeScenes = false gallery := makeGallery(i, includeScenes) - err := gqb.Create(ctx, gallery, fileIDs) + err := gqb.Create(ctx, &models.CreateGalleryInput{ + Gallery: gallery, + FileIDs: fileIDs, + CustomFields: getGalleryCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating gallery %v+: %s", gallery, err.Error()) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 53e62b166..7867054ba 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -20,6 +20,7 @@ var ( performersGalleriesJoinTable = goqu.T(performersGalleriesTable) galleriesScenesJoinTable = goqu.T(galleriesScenesTable) galleriesURLsJoinTable = goqu.T(galleriesURLsTable) + galleriesCustomFieldsTable = goqu.T("gallery_custom_fields") scenesFilesJoinTable = goqu.T(scenesFilesTable) scenesTagsJoinTable = goqu.T(scenesTagsTable) From ca5178f05ebd5f702e5c20e660a3c4c062ed0335 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:53:12 +1100 Subject: [PATCH 002/152] Backend support for Group custom fields (#6596) --- graphql/schema/types/filters.graphql | 3 + graphql/schema/types/group.graphql | 7 + internal/api/loaders/dataloaders.go | 28 +- internal/api/resolver_model_movie.go | 13 + internal/api/resolver_mutation_group.go | 57 ++-- internal/manager/repository.go | 2 +- pkg/group/create.go | 24 +- pkg/group/export.go | 60 ++-- pkg/group/export_test.go | 62 +++- pkg/group/import.go | 9 + pkg/group/import_test.go | 21 +- pkg/group/service.go | 1 + pkg/models/group.go | 2 + pkg/models/jsonschema/group.go | 2 + pkg/models/mocks/GroupReaderWriter.go | 60 ++++ pkg/models/model_group.go | 10 + pkg/models/repository_group.go | 2 + pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 8 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery_test.go | 73 ++++ pkg/sqlite/group.go | 9 + pkg/sqlite/group_filter.go | 7 + pkg/sqlite/group_test.go | 312 ++++++++++++++++++ .../migrations/82_group_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 19 ++ pkg/sqlite/tables.go | 1 + 27 files changed, 725 insertions(+), 82 deletions(-) create mode 100644 pkg/sqlite/migrations/82_group_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d683329b6..4162f0af3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -462,6 +462,9 @@ input GroupFilterType { scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input StudioFilterType { diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a46932054..a1c878923 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -31,6 +31,7 @@ type Group { sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! o_counter: Int # Resolver + custom_fields: Map! } input GroupDescriptionInput { @@ -59,6 +60,8 @@ input GroupCreateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: Map } input GroupUpdateInput { @@ -82,6 +85,8 @@ input GroupUpdateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: CustomFieldsInput } input BulkUpdateGroupDescriptionsInput { @@ -101,6 +106,8 @@ input BulkGroupUpdateInput { containing_groups: BulkUpdateGroupDescriptionsInput sub_groups: BulkUpdateGroupDescriptionsInput + + custom_fields: CustomFieldsInput } input GroupDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index e7293ad1c..ff8a87ab0 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -64,11 +64,12 @@ type Loaders struct { StudioByID *StudioLoader StudioCustomFields *CustomFieldsLoader - TagByID *TagLoader - TagCustomFields *CustomFieldsLoader - GroupByID *GroupLoader - FileByID *FileLoader - FolderByID *FolderLoader + TagByID *TagLoader + TagCustomFields *CustomFieldsLoader + GroupByID *GroupLoader + GroupCustomFields *CustomFieldsLoader + FileByID *FileLoader + FolderByID *FolderLoader } type Middleware struct { @@ -139,6 +140,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGroups(ctx), }, + GroupCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGroupCustomFields(ctx), + }, FileByID: &FileLoader{ wait: wait, maxBatch: maxBatch, @@ -325,6 +331,18 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 317123c6e..287d5d51a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -215,3 +215,16 @@ func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *i } return &count, nil } + +func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index 14dc817b9..dff5a6c1e 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -14,13 +14,17 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, error) { +func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new group from the input - newGroup := models.NewGroup() + newGroupInput := &models.CreateGroupInput{ + Group: &models.Group{}, + } + *newGroupInput.Group = models.NewGroup() + newGroup := newGroupInput.Group newGroup.Name = strings.TrimSpace(input.Name) newGroup.Aliases = translator.string(input.Aliases) @@ -59,28 +63,19 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } - return &newGroup, nil -} - -func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { - newGroup, err := groupFromGroupCreateInput(ctx, input) - if err != nil { - return nil, err - } + newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string - var frontimageData []byte if input.FrontImage != nil { - frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) + newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } // Process the base 64 encoded image string - var backimageData []byte if input.BackImage != nil { - backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) + newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } @@ -88,13 +83,22 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. - if len(frontimageData) == 0 && len(backimageData) != 0 { - frontimageData = static.ReadAll(static.DefaultGroupImage) + if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 { + newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage) + } + + return newGroupInput, nil +} + +func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { + createGroupInput, err := groupFromGroupCreateInput(ctx, input) + if err != nil { + return nil, err } // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil { + if err = r.groupService.Create(ctx, createGroupInput); err != nil { return err } @@ -104,9 +108,9 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) - return r.getGroup(ctx, newGroup.ID) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil) + return r.getGroup(ctx, createGroupInput.Group.ID) } func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { @@ -150,6 +154,12 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou } updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } return updatedGroup, nil } @@ -246,6 +256,13 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } + return updatedGroup, nil } diff --git a/internal/manager/repository.go b/internal/manager/repository.go index afbf0b963..65514ed1d 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -39,7 +39,7 @@ type GalleryService interface { } type GroupService interface { - Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error + Create(ctx context.Context, input *models.CreateGroupInput) error UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error diff --git a/pkg/group/create.go b/pkg/group/create.go index 56d6b7a4e..9cc578b23 100644 --- a/pkg/group/create.go +++ b/pkg/group/create.go @@ -12,27 +12,37 @@ var ( ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups") ) -func (s *Service) Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error { +func (s *Service) Create(ctx context.Context, input *models.CreateGroupInput) error { r := s.Repository + group := input.Group if err := s.validateCreate(ctx, group); err != nil { return err } - err := r.Create(ctx, group) + err := r.Create(ctx, input.Group) if err != nil { return err } - // update image table - if len(frontimageData) > 0 { - if err := r.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { + // set custom fields + if len(input.CustomFields) > 0 { + if err := r.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{ + Full: input.CustomFields, + }); err != nil { return err } } - if len(backimageData) > 0 { - if err := r.UpdateBackImage(ctx, group.ID, backimageData); err != nil { + // update image table + if len(input.FrontImageData) > 0 { + if err := r.UpdateFrontImage(ctx, group.ID, input.FrontImageData); err != nil { + return err + } + } + + if len(input.BackImageData) > 0 { + if err := r.UpdateBackImage(ctx, group.ID, input.BackImageData); err != nil { return err } } diff --git a/pkg/group/export.go b/pkg/group/export.go index 418ce7bed..0a56fbdbb 100644 --- a/pkg/group/export.go +++ b/pkg/group/export.go @@ -11,61 +11,67 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type ImageGetter interface { - GetFrontImage(ctx context.Context, movieID int) ([]byte, error) - GetBackImage(ctx context.Context, movieID int) ([]byte, error) +type GroupExportReader interface { + GetFrontImage(ctx context.Context, groupID int) ([]byte, error) + GetBackImage(ctx context.Context, groupID int) ([]byte, error) + GetCustomFields(ctx context.Context, groupID int) (map[string]interface{}, error) } -// ToJSON converts a Movie into its JSON equivalent. -func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioGetter, movie *models.Group) (*jsonschema.Group, error) { - newMovieJSON := jsonschema.Group{ - Name: movie.Name, - Aliases: movie.Aliases, - Director: movie.Director, - Synopsis: movie.Synopsis, - URLs: movie.URLs.List(), - CreatedAt: json.JSONTime{Time: movie.CreatedAt}, - UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, +// ToJSON converts a Group into its JSON equivalent. +func ToJSON(ctx context.Context, reader GroupExportReader, studioReader models.StudioGetter, group *models.Group) (*jsonschema.Group, error) { + newGroupJSON := jsonschema.Group{ + Name: group.Name, + Aliases: group.Aliases, + Director: group.Director, + Synopsis: group.Synopsis, + URLs: group.URLs.List(), + CreatedAt: json.JSONTime{Time: group.CreatedAt}, + UpdatedAt: json.JSONTime{Time: group.UpdatedAt}, } - if movie.Date != nil { - newMovieJSON.Date = movie.Date.String() + if group.Date != nil { + newGroupJSON.Date = group.Date.String() } - if movie.Rating != nil { - newMovieJSON.Rating = *movie.Rating + if group.Rating != nil { + newGroupJSON.Rating = *group.Rating } - if movie.Duration != nil { - newMovieJSON.Duration = *movie.Duration + if group.Duration != nil { + newGroupJSON.Duration = *group.Duration } - if movie.StudioID != nil { - studio, err := studioReader.Find(ctx, *movie.StudioID) + if group.StudioID != nil { + studio, err := studioReader.Find(ctx, *group.StudioID) if err != nil { return nil, fmt.Errorf("error getting movie studio: %v", err) } if studio != nil { - newMovieJSON.Studio = studio.Name + newGroupJSON.Studio = studio.Name } } - frontImage, err := reader.GetFrontImage(ctx, movie.ID) + frontImage, err := reader.GetFrontImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie front image: %v", err) } if len(frontImage) > 0 { - newMovieJSON.FrontImage = utils.GetBase64StringFromData(frontImage) + newGroupJSON.FrontImage = utils.GetBase64StringFromData(frontImage) } - backImage, err := reader.GetBackImage(ctx, movie.ID) + backImage, err := reader.GetBackImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie back image: %v", err) } if len(backImage) > 0 { - newMovieJSON.BackImage = utils.GetBase64StringFromData(backImage) + newGroupJSON.BackImage = utils.GetBase64StringFromData(backImage) } - return &newMovieJSON, nil + newGroupJSON.CustomFields, err = reader.GetCustomFields(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("getting group custom fields: %v", err) + } + + return &newGroupJSON, nil } diff --git a/pkg/group/export_test.go b/pkg/group/export_test.go index 5f8d9f7dc..bff50de5e 100644 --- a/pkg/group/export_test.go +++ b/pkg/group/export_test.go @@ -8,24 +8,26 @@ import ( "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "testing" "time" ) const ( - movieID = 1 - emptyID = 2 - errFrontImageID = 3 - errBackImageID = 4 - errStudioMovieID = 5 - missingStudioMovieID = 6 + movieID = iota + 1 + emptyID + errFrontImageID + errBackImageID + errStudioMovieID + missingStudioMovieID + errCustomFieldsID ) const ( - studioID = 1 - missingStudioID = 2 - errStudioID = 3 + studioID = iota + 1 + missingStudioID + errStudioID ) const movieName = "testMovie" @@ -51,6 +53,11 @@ const ( var ( frontImageBytes = []byte("frontImageBytes") backImageBytes = []byte("backImageBytes") + + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) var movieStudio models.Studio = models.Studio{ @@ -88,7 +95,7 @@ func createEmptyMovie(id int) models.Group { } } -func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group { +func createFullJSONMovie(studio, frontImage, backImage string, customFields map[string]interface{}) *jsonschema.Group { return &jsonschema.Group{ Name: movieName, Aliases: movieAliases, @@ -107,6 +114,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } @@ -119,13 +127,15 @@ func createEmptyJSONMovie() *jsonschema.Group { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: emptyCustomFields, } } type testScenario struct { - movie models.Group - expected *jsonschema.Group - err bool + movie models.Group + customFields map[string]interface{} + expected *jsonschema.Group + err bool } var scenarios []testScenario @@ -134,36 +144,48 @@ func initTestTable() { scenarios = []testScenario{ { createFullMovie(movieID, studioID), - createFullJSONMovie(studioName, frontImage, backImage), + customFields, + createFullJSONMovie(studioName, frontImage, backImage, customFields), false, }, { createEmptyMovie(emptyID), + emptyCustomFields, createEmptyJSONMovie(), false, }, { createFullMovie(errFrontImageID, studioID), - createFullJSONMovie(studioName, "", backImage), + emptyCustomFields, + createFullJSONMovie(studioName, "", backImage, emptyCustomFields), // failure to get front image should not cause error false, }, { createFullMovie(errBackImageID, studioID), - createFullJSONMovie(studioName, frontImage, ""), + emptyCustomFields, + createFullJSONMovie(studioName, frontImage, "", emptyCustomFields), // failure to get back image should not cause error false, }, { createFullMovie(errStudioMovieID, errStudioID), + emptyCustomFields, nil, true, }, { createFullMovie(missingStudioMovieID, missingStudioID), - createFullJSONMovie("", frontImage, backImage), + emptyCustomFields, + createFullJSONMovie("", frontImage, backImage, emptyCustomFields), false, }, + { + createFullMovie(errCustomFieldsID, studioID), + customFields, + nil, + true, + }, } } @@ -179,6 +201,7 @@ func TestToJSON(t *testing.T) { db.Group.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() db.Group.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() db.Group.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Group.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() db.Group.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() @@ -186,6 +209,11 @@ func TestToJSON(t *testing.T) { db.Group.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() db.Group.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() db.Group.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() + db.Group.On("GetBackImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() + + db.Group.On("GetCustomFields", testCtx, movieID).Return(customFields, nil).Once() + db.Group.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() + db.Group.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil).Times(4) studioErr := errors.New("error getting studio") diff --git a/pkg/group/import.go b/pkg/group/import.go index d7acad47c..1a332bac2 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -14,6 +14,7 @@ import ( type ImporterReaderWriter interface { models.GroupCreatorUpdater + models.CustomFieldsWriter FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } @@ -233,6 +234,14 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { } } + if len(i.Input.CustomFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.Input.CustomFields, + }); err != nil { + return fmt.Errorf("error setting custom fields: %v", err) + } + } + if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { return fmt.Errorf("error setting group front image: %v", err) diff --git a/pkg/group/import_test.go b/pkg/group/import_test.go index 387ceb87e..006c91327 100644 --- a/pkg/group/import_test.go +++ b/pkg/group/import_test.go @@ -259,17 +259,29 @@ func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Group, - StudioWriter: db.Studio, + ReaderWriter: db.Group, + StudioWriter: db.Studio, + Input: jsonschema.Group{ + CustomFields: customFields, + }, frontImageData: frontImageBytes, backImageData: backImageBytes, } updateMovieImageErr := errors.New("UpdateImages error") + customFieldsErr := errors.New("SetCustomFields error") + + customFieldsInput := models.CustomFieldsInput{ + Full: customFields, + } db.Group.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() - db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() db.Group.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() + db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() + + db.Group.On("SetCustomFields", testCtx, movieID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errImageID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errCustomFieldsID, customFieldsInput).Return(customFieldsErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) @@ -277,6 +289,9 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) + err = i.PostImport(testCtx, errCustomFieldsID) + assert.NotNil(t, err) + db.AssertExpectations(t) } diff --git a/pkg/group/service.go b/pkg/group/service.go index ff6e03541..37094665a 100644 --- a/pkg/group/service.go +++ b/pkg/group/service.go @@ -10,6 +10,7 @@ type CreatorUpdater interface { models.GroupGetter models.GroupCreator models.GroupUpdater + models.CustomFieldsWriter models.ContainingGroupLoader models.SubGroupLoader diff --git a/pkg/models/group.go b/pkg/models/group.go index ec550eea8..396384b51 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -43,4 +43,6 @@ type GroupFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } diff --git a/pkg/models/jsonschema/group.go b/pkg/models/jsonschema/group.go index b284dab6e..357ac70bc 100644 --- a/pkg/models/jsonschema/group.go +++ b/pkg/models/jsonschema/group.go @@ -33,6 +33,8 @@ type Group struct { CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + // deprecated - for import only URL string `json:"url,omitempty"` } diff --git a/pkg/models/mocks/GroupReaderWriter.go b/pkg/models/mocks/GroupReaderWriter.go index dc745d094..ac9e513f4 100644 --- a/pkg/models/mocks/GroupReaderWriter.go +++ b/pkg/models/mocks/GroupReaderWriter.go @@ -312,6 +312,52 @@ func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GroupReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GroupReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) @@ -497,6 +543,20 @@ func (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GroupReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGroup func (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error { ret := _m.Called(ctx, updatedGroup) diff --git a/pkg/models/model_group.go b/pkg/models/model_group.go index 82c71996a..5bfb42c44 100644 --- a/pkg/models/model_group.go +++ b/pkg/models/model_group.go @@ -34,6 +34,14 @@ func NewGroup() Group { } } +type CreateGroupInput struct { + *Group + + CustomFields map[string]interface{} `json:"custom_fields"` + FrontImageData []byte + BackImageData []byte +} + func (m *Group) LoadURLs(ctx context.Context, l URLLoader) error { return m.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, m.ID) @@ -74,6 +82,8 @@ type GroupPartial struct { SubGroups *UpdateGroupDescriptions CreatedAt OptionalTime UpdatedAt OptionalTime + + CustomFields CustomFieldsInput } func NewGroupPartial() GroupPartial { diff --git a/pkg/models/repository_group.go b/pkg/models/repository_group.go index 704390d77..d7f74de64 100644 --- a/pkg/models/repository_group.go +++ b/pkg/models/repository_group.go @@ -68,6 +68,7 @@ type GroupReader interface { TagIDLoader ContainingGroupLoader SubGroupLoader + CustomFieldsReader All(ctx context.Context) ([]*Group, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) @@ -81,6 +82,7 @@ type GroupWriter interface { GroupCreator GroupUpdater GroupDestroyer + CustomFieldsWriter } // GroupReaderWriter provides all group methods. diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 6a5cd4da5..ace306169 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -964,6 +964,10 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(groupsCustomFieldsTable.GetTable()), "group_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ae4a276f7..2f7ecd7dc 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -242,7 +242,13 @@ func TestSceneSetCustomFields(t *testing.T) { } func TestGallerySetCustomFields(t *testing.T) { - galleryIdx := galleryIdxWithScene + galleryIdx := galleryIdxWithChapters testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) } + +func TestGroupSetCustomFields(t *testing.T) { + groupIdx := groupIdxWithScene + + testSetCustomFields(t, "Group", db.Group, groupIDs[groupIdx], getGroupCustomFields(groupIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index d95832836..000b91c4d 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 81 +var appSchemaVersion uint = 82 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 4156f129c..9bd0da47f 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -831,6 +831,79 @@ func Test_galleryQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_GalleryStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GalleryPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + galleryIDs[galleryIdxWithTwoTags], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Gallery + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GalleryStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GalleryStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_galleryQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index b216335b8..13a6905a5 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -131,6 +131,7 @@ var ( type GroupStore struct { blobJoinQueryBuilder + customFieldsStore tagRelationshipStore groupRelationshipStore @@ -143,6 +144,10 @@ func NewGroupStore(blobStore *BlobStore) *GroupStore { blobStore: blobStore, joinTable: groupTable, }, + customFieldsStore: customFieldsStore{ + table: groupsCustomFieldsTable, + fk: groupsCustomFieldsTable.Col(groupIDColumn), + }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: groupsTagsTableMgr, @@ -235,6 +240,10 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index f81783374..4f3f7b41a 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -84,6 +84,13 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, + &customFieldsFilterHandler{ + table: groupsCustomFieldsTable.GetTable(), + fkCol: groupIDColumn, + c: groupFilter.CustomFields, + idCol: "groups.id", + }, + &relatedFilterHandler{ relatedIDCol: "groups_scenes.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index db293dd92..22b551e02 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -566,6 +566,79 @@ func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { } } +func Test_GroupStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GroupPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + groupIDs[groupIdxWithTwoTags], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(3), + "real": 0.3, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Group + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GroupStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GroupStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func TestGroupFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Group @@ -1917,6 +1990,245 @@ func TestGroupFindSubGroupIDs(t *testing.T) { } } +func TestGroupQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GroupFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "not equals", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "includes", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "excludes", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + []int{groupIdxWithChildWithScene}, + nil, + false, + }, + { + "invalid regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChildWithScene, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + nil, + []int{groupIdxWithChildWithScene}, + false, + }, + { + "invalid not matches regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "not null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "between", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{groupIdxWithTag}, + nil, + false, + }, + { + "not between", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithTag, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{groupIdxWithTag}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + groups, _, err := db.Group.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GroupStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := groupsToIDs(groups) + include := indexesToIDs(groupIDs, tt.includeIdxs) + exclude := indexesToIDs(groupIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Update // TODO Destroy - ensure image is destroyed // TODO Find diff --git a/pkg/sqlite/migrations/82_group_custom_fields.up.sql b/pkg/sqlite/migrations/82_group_custom_fields.up.sql new file mode 100644 index 000000000..c1f287fec --- /dev/null +++ b/pkg/sqlite/migrations/82_group_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `group_custom_fields` ( + `group_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`group_id`, `field`), + foreign key(`group_id`) references `groups`(`id`) on delete CASCADE +); + +CREATE INDEX `index_group_custom_fields_field_value` ON `group_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 675b3f417..0078f1a67 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1457,6 +1457,18 @@ func getGroupEmptyString(index int, field string) string { return v.String } +func getGroupCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGroupStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + // createGroups creates n groups with plain Name and o groups with camel cased NaMe included func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1489,6 +1501,13 @@ func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o in return fmt.Errorf("Error creating group [%d] %v+: %s", i, group, err.Error()) } + customFields := getGroupCustomFields(i) + if customFields != nil { + if err := mqb.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{Full: customFields}); err != nil { + return fmt.Errorf("Error setting custom fields for group %d: %s", group.ID, err.Error()) + } + } + groupIDs = append(groupIDs, group.ID) groupNames = append(groupNames, group.Name) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7867054ba..6c898048d 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -47,6 +47,7 @@ var ( groupsURLsJoinTable = goqu.T(groupURLsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable) groupRelationsJoinTable = goqu.T(groupRelationsTable) + groupsCustomFieldsTable = goqu.T("group_custom_fields") tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) From 9a1b1fb7187eb6f2fdaffd6df777a75ff470b7ea Mon Sep 17 00:00:00 2001 From: 1509x Date: Sun, 22 Feb 2026 20:51:35 -0500 Subject: [PATCH 003/152] [Feature] Reveal file in system file manager from file info panel (#6587) * Add reveal in file manager button to file info panel Adds a folder icon button next to the path field in the Scene, Image, and Gallery file info panels. Clicking it calls a new GraphQL mutation that opens the file's enclosing directory in the system file manager (Finder on macOS, Explorer on Windows, xdg-open on Linux). Also fixes the existing revealInFileManager implementations which were constructing exec.Command but never calling Run(), making them no-ops: - darwin: add Run() to open -R - windows: add Run() and fix flag from \select to /select, - linux: implement with xdg-open on the parent directory - desktop.go: use os.Stat instead of FileExists so folders work too * Disallow reveal operation if request not from loopback --------- Co-authored-by: 1509x <1509x@users.noreply.github.com> Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 4 ++ internal/api/authentication.go | 2 + internal/api/resolver_mutation_file.go | 71 +++++++++++++++++++ internal/desktop/desktop.go | 15 ++-- internal/desktop/desktop_platform_darwin.go | 11 ++- internal/desktop/desktop_platform_nixes.go | 13 +++- internal/desktop/desktop_platform_windows.go | 9 ++- pkg/session/local.go | 44 ++++++++++++ pkg/session/session.go | 1 + ui/v2.5/graphql/mutations/file.graphql | 8 +++ .../GalleryDetails/GalleryFileInfoPanel.tsx | 18 +++-- .../ImageDetails/ImageFileInfoPanel.tsx | 13 ++-- .../SceneDetails/SceneFileInfoPanel.tsx | 13 ++-- .../Shared/RevealInFilesystemButton.tsx | 48 +++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 5 ++ ui/v2.5/src/core/StashService.ts | 12 ++++ ui/v2.5/src/docs/en/Manual/Browsing.md | 6 ++ ui/v2.5/src/index.scss | 1 + ui/v2.5/src/locales/en-GB.json | 1 + 19 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 pkg/session/local.go create mode 100644 ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7fda85b24..996afefe7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -426,6 +426,10 @@ type Mutation { destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! + "Reveal the file in the system file manager" + revealFileInFileManager(id: ID!): Boolean! + "Reveal the folder in the system file manager" + revealFolderInFileManager(id: ID!): Boolean! # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 6ad7117a1..be399d222 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -40,6 +40,8 @@ func authenticateHandler() func(http.Handler) http.Handler { return } + r = session.SetLocalRequest(r) + userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if !errors.Is(err, session.ErrUnauthorized) { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index afbefe554..f6279ad16 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -5,10 +5,13 @@ import ( "fmt" "strconv" + "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -326,3 +329,71 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe return true, nil } + +func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal file in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + fileIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var filePath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt)) + if err != nil { + return fmt.Errorf("finding file: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("file with id %d not found", fileIDInt) + } + filePath = files[0].Base().Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(filePath); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal folder in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + folderIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var folderPath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt)) + if err != nil { + return fmt.Errorf("finding folder: %w", err) + } + if folder == nil { + return fmt.Errorf("folder with id %d not found", folderIDInt) + } + folderPath = folder.Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(folderPath); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 06d400793..f1ca9bc92 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -2,6 +2,7 @@ package desktop import ( + "fmt" "os" "path" "path/filepath" @@ -155,15 +156,17 @@ func getIconPath() string { return path.Join(config.GetInstance().GetConfigPath(), "icon.png") } -func RevealInFileManager(path string) { - exists, err := fsutil.FileExists(path) +func RevealInFileManager(path string) error { + info, err := os.Stat(path) if err != nil { - logger.Errorf("Error checking file: %s", err) - return + return fmt.Errorf("error checking path: %w", err) } - if exists && IsDesktop() { - revealInFileManager(path) + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("error getting absolute path: %w", err) } + return revealInFileManager(absPath, info) } func getServerURL(path string) string { diff --git a/internal/desktop/desktop_platform_darwin.go b/internal/desktop/desktop_platform_darwin.go index 593e9516f..732009007 100644 --- a/internal/desktop/desktop_platform_darwin.go +++ b/internal/desktop/desktop_platform_darwin.go @@ -4,9 +4,11 @@ package desktop import ( + "fmt" + "os" "os/exec" - "github.com/kermieisinthehouse/gosx-notifier" + gosxnotifier "github.com/kermieisinthehouse/gosx-notifier" "github.com/stashapp/stash/pkg/logger" ) @@ -32,8 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`open`, `-R`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + if err := exec.Command(`open`, `-R`, path).Run(); err != nil { + return fmt.Errorf("error revealing path in Finder: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_nixes.go b/internal/desktop/desktop_platform_nixes.go index 69c780d3c..f5ab13384 100644 --- a/internal/desktop/desktop_platform_nixes.go +++ b/internal/desktop/desktop_platform_nixes.go @@ -4,8 +4,10 @@ package desktop import ( + "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" @@ -33,8 +35,15 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - +func revealInFileManager(path string, info os.FileInfo) error { + dir := path + if !info.IsDir() { + dir = filepath.Dir(path) + } + if err := exec.Command("xdg-open", dir).Run(); err != nil { + return fmt.Errorf("error opening directory in file manager: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_windows.go b/internal/desktop/desktop_platform_windows.go index ecb4060e6..48feabed5 100644 --- a/internal/desktop/desktop_platform_windows.go +++ b/internal/desktop/desktop_platform_windows.go @@ -4,6 +4,7 @@ package desktop import ( + "os" "os/exec" "syscall" "unsafe" @@ -83,6 +84,10 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`explorer`, `\select`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + c := exec.Command(`explorer`, `/select,`, path) + logger.Debugf("Running: %s", c.String()) + // explorer seems to return an error code even when it works, so ignore the error + _ = c.Run() + return nil } diff --git a/pkg/session/local.go b/pkg/session/local.go new file mode 100644 index 000000000..519328496 --- /dev/null +++ b/pkg/session/local.go @@ -0,0 +1,44 @@ +package session + +import ( + "context" + "net" + "net/http" + + "github.com/stashapp/stash/pkg/logger" +) + +// SetLocalRequest checks if the request is from localhost and sets the context value accordingly. +// It returns the modified request with the updated context, or the original request if it did +// not come from localhost or if there was an error parsing the remote address. +func SetLocalRequest(r *http.Request) *http.Request { + // determine if request is from localhost + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.Errorf("Error parsing remote address: %v", err) + return r + } + + ip := net.ParseIP(host) + if ip == nil { + logger.Errorf("Error parsing IP address: %s", host) + return r + } + + if ip.IsLoopback() { + ctx := context.WithValue(r.Context(), contextLocalRequest, true) + r = r.WithContext(ctx) + } + + return r +} + +// IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest. +// If the context value is not set, it returns false. +func IsLocalRequest(ctx context.Context) bool { + val := ctx.Value(contextLocalRequest) + if val == nil { + return false + } + return val.(bool) +} diff --git a/pkg/session/session.go b/pkg/session/session.go index 66cb39e09..3e4c2eea1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -15,6 +15,7 @@ type key int const ( contextUser key = iota contextVisitedPlugins + contextLocalRequest ) const ( diff --git a/ui/v2.5/graphql/mutations/file.graphql b/ui/v2.5/graphql/mutations/file.graphql index 254a55126..fe920d308 100644 --- a/ui/v2.5/graphql/mutations/file.graphql +++ b/ui/v2.5/graphql/mutations/file.graphql @@ -1,3 +1,11 @@ mutation DeleteFiles($ids: [ID!]!) { deleteFiles(ids: $ids) } + +mutation RevealFileInFileManager($id: ID!) { + revealFileInFileManager(id: $id) +} + +mutation RevealFolderInFileManager($id: ID!) { + revealFolderInFileManager(id: $id) +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index 63fedd400..e97146b91 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -3,11 +3,12 @@ import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateGallerySetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; -import { TextField, URLField, URLsField } from "src/utils/field"; +import { TextField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; @@ -38,12 +39,15 @@ const FileInfoPanel: React.FC = ( )} - + + + + + + {props.file && ( = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 63490a2ee..6be55925e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -9,6 +9,7 @@ import { import { useHistory } from "react-router-dom"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; @@ -70,12 +71,12 @@ const FileInfoPanel: React.FC = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx new file mode 100644 index 000000000..ecc03f9f7 --- /dev/null +++ b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import { + mutateRevealFileInFileManager, + mutateRevealFolderInFileManager, +} from "src/core/StashService"; +import { getPlatformURL } from "src/core/createClient"; + +interface IRevealInFilesystemButtonProps { + fileId?: string; + folderId?: string; +} + +function isLocalhost(): boolean { + const { hostname } = getPlatformURL(); + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); +} + +export const RevealInFilesystemButton: React.FC< + IRevealInFilesystemButtonProps +> = ({ fileId, folderId }) => { + const intl = useIntl(); + + if (!isLocalhost()) return null; + + function onClick() { + if (folderId) { + mutateRevealFolderInFileManager(folderId); + } else if (fileId) { + mutateRevealFileInFileManager(fileId); + } + } + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f72bbbeea..32b222832 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1204,3 +1204,8 @@ input[type="range"].double-range-slider-max { overflow-y: auto; } } + +.reveal-in-filesystem-button { + margin-left: 0.25rem; + padding: 0 0.25rem; +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 58b1aae42..d276806fc 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2248,6 +2248,18 @@ export const mutateDeleteFiles = (ids: string[]) => }, }); +export const mutateRevealFileInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFileInFileManagerDocument, + variables: { id }, + }); + +export const mutateRevealFolderInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFolderInFileManagerDocument, + variables: { id }, + }); + /// Scrapers export const useListSceneScrapers = () => GQL.useListSceneScrapersQuery(); diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index 69277146e..6b6681253 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -50,3 +50,9 @@ Saved filters are sorted alphabetically by title with capitalized titles sorted ### Default filter The default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu. + +## Reveal file in file manager + +The `Reveal in file manager` action is available for file-based scenes, galleries and images in the `File Info` tab. This action will open the file manager to the location of the file on disk. The file will be selected if supported by the file manager. + +This button will only be available when accessing stash from a local loopback address (e.g. `localhost` or `127.0.0.1`), and will not be shown when accessing stash from a remote address. \ No newline at end of file diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index cadd1ad2f..3d9478194 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -84,6 +84,7 @@ code, } dd { + overflow: hidden; white-space: pre-line; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4bfd4322d..957bf2837 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -98,6 +98,7 @@ "remove_from_containing_group": "Remove from Group", "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", + "reveal_in_file_manager": "Reveal in File Manager", "rescan": "Rescan", "reset_play_duration": "Reset play duration", "reset_resume_time": "Reset resume time", From aff6db15009e83192b9e2ef82107eb6b29073bd7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:51:36 +1100 Subject: [PATCH 004/152] Fix scene player scrubber when custom sprite size used (#6597) --- .../ScenePlayer/ScenePlayerScrubber.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 93e45a7e7..196bc9bd0 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -28,6 +28,10 @@ interface ISceneSpriteItem { time: string; } +const scrubberViewportHeight = 120; +const scrubberTagsHeight = 30; +const scrubberSpriteHeight = scrubberViewportHeight - scrubberTagsHeight; + export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -86,16 +90,36 @@ export const ScenePlayerScrubber: React.FC = ({ const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!spriteInfo) return; + if (!spriteInfo || spriteInfo.length === 0) return; let totalWidth = 0; + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + const spriteWidth = spriteInfo[0].w; + const spriteHeight = spriteInfo[0].h; + const scale = scrubberSpriteHeight / spriteHeight; + + const w = spriteWidth * scale; + const h = scrubberSpriteHeight; + + const sizeX = maxX * scale; + const sizeY = maxY * scale; + + // scale sprite dimensions to fit scrubber height, and calculate background position for each sprite const newSprites = spriteInfo?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; + totalWidth += w; + const left = w * index; + + const spriteX = sprite.x * scale; + const spriteY = sprite.y * scale; + const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + width: `${w}px`, + height: `${h}px`, + backgroundPosition: `${-spriteX}px ${-spriteY}px`, backgroundImage: `url(${sprite.url})`, + backgroundSize: `${sizeX}px ${sizeY}px`, left: `${left}px`, }; const start = TextUtils.secondsToTimestamp(sprite.start); From 86abe7b24c79fd82fe7eb4543e11e1cace8a5182 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:41:40 +1100 Subject: [PATCH 005/152] Backend support for image custom fields (#6598) * Initialise maps in bulk get custom fields to fix graphql validation error --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/image.graphql | 3 + internal/api/loaders/dataloaders.go | 18 + internal/api/resolver_model_image.go | 9 + internal/api/resolver_mutation_image.go | 14 + internal/autotag/integration_test.go | 5 +- internal/manager/task_export.go | 8 +- pkg/image/export.go | 15 +- pkg/image/export_test.go | 26 +- pkg/image/import.go | 13 +- pkg/image/import_test.go | 4 +- pkg/image/scan.go | 7 +- pkg/models/image.go | 39 +- pkg/models/jsonschema/image.go | 25 +- pkg/models/mocks/ImageReaderWriter.go | 70 ++- pkg/models/model_image.go | 8 + pkg/models/repository_image.go | 4 +- pkg/sqlite/custom_fields.go | 4 + pkg/sqlite/custom_fields_test.go | 6 + pkg/sqlite/database.go | 2 +- pkg/sqlite/image.go | 26 +- pkg/sqlite/image_filter.go | 7 + pkg/sqlite/image_test.go | 437 +++++++++++++++--- .../migrations/83_image_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/tables.go | 1 + 26 files changed, 669 insertions(+), 111 deletions(-) create mode 100644 pkg/sqlite/migrations/83_image_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4162f0af3..907e597f4 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -765,6 +765,8 @@ input ImageFilterType { tags_filter: TagFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input FileFilterType { diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index b7ec1a9f5..ccc414542 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -21,6 +21,7 @@ type Image { studio: Studio tags: [Tag!]! performers: [Performer!]! + custom_fields: Map! } type ImageFileType { @@ -56,6 +57,7 @@ input ImageUpdateInput { gallery_ids: [ID!] primary_file_id: ID + custom_fields: CustomFieldsInput } input BulkImageUpdateInput { @@ -76,6 +78,7 @@ input BulkImageUpdateInput { performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds gallery_ids: BulkUpdateIds + custom_fields: CustomFieldsInput } input ImageDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index ff8a87ab0..dac8ba6b8 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -57,6 +57,7 @@ type Loaders struct { GalleryByID *GalleryLoader GalleryCustomFields *CustomFieldsLoader ImageByID *ImageLoader + ImageCustomFields *CustomFieldsLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -100,6 +101,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchImages(ctx), }, + ImageCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchImageCustomFields(ctx), + }, PerformerByID: &PerformerLoader{ wait: wait, maxBatch: maxBatch, @@ -249,6 +255,18 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { return func(keys []int) (ret []*models.Gallery, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 0886bea40..4a95ae1f4 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -161,3 +161,12 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, return obj.URLs.List(), nil } + +func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) { + customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + return customFields, nil +} diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 230d48358..cc03c5286 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -177,6 +177,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUp return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + qb := r.repository.Image image, err := qb.UpdatePartial(ctx, imageID, updatedImage) if err != nil { @@ -237,6 +244,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + // Start the transaction and save the images if err := r.withTxn(ctx, func(ctx context.Context) error { var updatedGalleryIDs []int diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 9745d623e..f537ecfe7 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -365,7 +365,10 @@ func makeImage(expectedResult bool) *models.Image { } func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateImageInput{ + Image: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error()) diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 30adf626b..01bab9430 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -651,6 +651,7 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag + imageReader := r.Image for s := range jobChan { imageHash := s.Checksum @@ -665,14 +666,17 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha continue } - newImageJSON := image.ToBasicJSON(s) + newImageJSON, err := image.ToBasicJSON(ctx, imageReader, s) + if err != nil { + logger.Errorf("[images] <%s> error converting image to JSON: %v", imageHash, err) + continue + } // export files for _, f := range s.Files.List() { t.exportFile(f) } - var err error newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s) if err != nil { logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err) diff --git a/pkg/image/export.go b/pkg/image/export.go index fdba6165c..eb5d5da27 100644 --- a/pkg/image/export.go +++ b/pkg/image/export.go @@ -2,16 +2,21 @@ package image import ( "context" + "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) +type ExportReader interface { + models.CustomFieldsReader +} + // ToBasicJSON converts a image object into its JSON object equivalent. It // does not convert the relationships to other objects, with the exception // of cover image. -func ToBasicJSON(image *models.Image) *jsonschema.Image { +func ToBasicJSON(ctx context.Context, reader ExportReader, image *models.Image) (*jsonschema.Image, error) { newImageJSON := jsonschema.Image{ Title: image.Title, Code: image.Code, @@ -33,11 +38,17 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image { newImageJSON.Organized = image.Organized newImageJSON.OCounter = image.OCounter + var err error + newImageJSON.CustomFields, err = reader.GetCustomFields(ctx, image.ID) + if err != nil { + return nil, fmt.Errorf("getting image custom fields: %v", err) + } + for _, f := range image.Files.List() { newImageJSON.Files = append(newImageJSON.Files, f.Base().Path) } - return &newImageJSON + return &newImageJSON, nil } // GetStudioName returns the name of the provided image's studio. It returns an diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 6adaf1d33..d0d36afbb 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -29,6 +29,10 @@ var ( dateObj, _ = models.ParseDate(date) organized = true ocounter = 2 + + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) const ( @@ -60,7 +64,7 @@ func createFullImage(id int) models.Image { } } -func createFullJSONImage() *jsonschema.Image { +func createFullJSONImage(customFields map[string]interface{}) *jsonschema.Image { return &jsonschema.Image{ Title: title, OCounter: ocounter, @@ -75,28 +79,40 @@ func createFullJSONImage() *jsonschema.Image { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } type basicTestScenario struct { - input models.Image - expected *jsonschema.Image + input models.Image + customFields map[string]interface{} + expected *jsonschema.Image } var scenarios = []basicTestScenario{ { createFullImage(imageID), - createFullJSONImage(), + customFields, + createFullJSONImage(customFields), }, } func TestToJSON(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetCustomFields", testCtx, imageID).Return(customFields, nil).Once() + for i, s := range scenarios { image := s.input - json := ToBasicJSON(&image) + json, err := ToBasicJSON(testCtx, db.Image, &image) + if err != nil { + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + continue + } assert.Equal(t, s.expected, json, "[%d]", i) } + + db.AssertExpectations(t) } func createStudioImage(studioID int) models.Image { diff --git a/pkg/image/import.go b/pkg/image/import.go index c7ef7f00c..d8dfa987f 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -31,8 +31,9 @@ type Importer struct { Input jsonschema.Image MissingRefBehaviour models.ImportMissingRefEnum - ID int - image models.Image + ID int + image models.Image + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -58,6 +59,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -344,7 +347,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.image, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateImageInput{ + Image: &i.image, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating image: %v", err) } diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 5d01d4b97..a693c4568 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -45,7 +45,8 @@ func TestImporterPreImportWithStudio(t *testing.T) { i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ - Studio: existingStudioName, + Studio: existingStudioName, + CustomFields: customFields, }, } @@ -57,6 +58,7 @@ func TestImporterPreImportWithStudio(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.image.StudioID) + assert.Equal(t, customFields, i.customFields) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 67f4b334c..317e3605f 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -27,7 +27,7 @@ type ScanCreatorUpdater interface { GetFiles(ctx context.Context, relatedID int) ([]models.File, error) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) - Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error + Create(ctx context.Context, newImage *models.CreateImageInput) error UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -124,7 +124,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) } - if err := h.CreatorUpdater.Create(ctx, &newImage, []models.FileID{imageFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateImageInput{ + Image: &newImage, + FileIDs: []models.FileID{imageFile.ID}, + }); err != nil { return fmt.Errorf("creating new image: %w", err) } diff --git a/pkg/models/image.go b/pkg/models/image.go index 84be79360..b99267e8c 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -1,6 +1,8 @@ package models -import "context" +import ( + "context" +) type ImageFilterType struct { OperatorFilter[ImageFilterType] @@ -65,25 +67,28 @@ type ImageFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type ImageUpdateInput struct { - ClientMutationID *string `json:"clientMutationId"` - ID string `json:"id"` - Title *string `json:"title"` - Code *string `json:"code"` - Urls []string `json:"urls"` - Date *string `json:"date"` - Details *string `json:"details"` - Photographer *string `json:"photographer"` - Rating100 *int `json:"rating100"` - Organized *bool `json:"organized"` - SceneIds []string `json:"scene_ids"` - StudioID *string `json:"studio_id"` - TagIds []string `json:"tag_ids"` - PerformerIds []string `json:"performer_ids"` - GalleryIds []string `json:"gallery_ids"` - PrimaryFileID *string `json:"primary_file_id"` + ClientMutationID *string `json:"clientMutationId"` + ID string `json:"id"` + Title *string `json:"title"` + Code *string `json:"code"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Details *string `json:"details"` + Photographer *string `json:"photographer"` + Rating100 *int `json:"rating100"` + Organized *bool `json:"organized"` + SceneIds []string `json:"scene_ids"` + StudioID *string `json:"studio_id"` + TagIds []string `json:"tag_ids"` + PerformerIds []string `json:"performer_ids"` + GalleryIds []string `json:"gallery_ids"` + PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` // deprecated URL *string `json:"url"` diff --git a/pkg/models/jsonschema/image.go b/pkg/models/jsonschema/image.go index 1bdac8770..168ea9eec 100644 --- a/pkg/models/jsonschema/image.go +++ b/pkg/models/jsonschema/image.go @@ -18,18 +18,19 @@ type Image struct { // deprecated - for import only URL string `json:"url,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Galleries []GalleryRef `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - Files []string `json:"files,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + Files []string `json:"files,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Image) Filename(basename string, hash string) string { diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index afc5efdb7..f2c9934be 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -137,13 +137,13 @@ func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int return r0, r1 } -// Create provides a mock function with given fields: ctx, newImage, fileIDs -func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newImage, fileIDs) +// Create provides a mock function with given fields: ctx, newImage +func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.CreateImageInput) error { + ret := _m.Called(ctx, newImage) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Image, []models.FileID) error); ok { - r0 = rf(ctx, newImage, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateImageInput) error); ok { + r0 = rf(ctx, newImage) } else { r0 = ret.Error(0) } @@ -393,6 +393,52 @@ func (_m *ImageReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *ImageReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *ImageReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -694,6 +740,20 @@ func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, er return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *ImageReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Size provides a mock function with given fields: ctx func (_m *ImageReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 1d0993536..72ca61826 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -47,6 +47,13 @@ func NewImage() Image { } } +type CreateImageInput struct { + *Image + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + type ImagePartial struct { Title OptionalString Code OptionalString @@ -66,6 +73,7 @@ type ImagePartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + CustomFields CustomFieldsInput } func NewImagePartial() ImagePartial { diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 672ecd063..99dab3479 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -43,7 +43,7 @@ type ImageCounter interface { // ImageCreator provides methods to create images. type ImageCreator interface { - Create(ctx context.Context, newImage *Image, fileIDs []FileID) error + Create(ctx context.Context, newImage *CreateImageInput) error } // ImageUpdater provides methods to update images. @@ -78,6 +78,7 @@ type ImageReader interface { FileLoader GalleryCoverFinder + CustomFieldsReader All(ctx context.Context) ([]*Image, error) Size(ctx context.Context) (float64, error) @@ -88,6 +89,7 @@ type ImageWriter interface { ImageCreator ImageUpdater ImageDestroyer + CustomFieldsWriter AddFileID(ctx context.Context, id int, fileID FileID) error RemoveFileID(ctx context.Context, id int, fileID FileID) error diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index 63f85b250..d78e3f9ab 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -192,6 +192,10 @@ func (s *customFieldsStore) GetCustomFieldsBulk(ctx context.Context, ids []int) const single = false ret := make([]models.CustomFieldMap, len(ids)) + // initialise ret with empty maps for each id + for i := range ret { + ret[i] = make(map[string]interface{}) + } idi := make(map[int]int, len(ids)) for i, id := range ids { diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index 2f7ecd7dc..5d5545210 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -247,6 +247,12 @@ func TestGallerySetCustomFields(t *testing.T) { testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) } +func TestImageSetCustomFields(t *testing.T) { + imageIdx := imageIdx2WithGallery + + testSetCustomFields(t, "Image", db.Image, imageIDs[imageIdx], getImageCustomFields(imageIdx)) +} + func TestGroupSetCustomFields(t *testing.T) { groupIdx := groupIdxWithScene diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 000b91c4d..003c6eebc 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 82 +var appSchemaVersion uint = 83 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index bcaf3f42f..da1c67a10 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -185,6 +185,8 @@ var ( ) type ImageStore struct { + customFieldsStore + tableMgr *table oCounterManager @@ -193,6 +195,10 @@ type ImageStore struct { func NewImageStore(r *storeRepository) *ImageStore { return &ImageStore{ + customFieldsStore: customFieldsStore{ + table: imagesCustomFieldsTable, + fk: imagesCustomFieldsTable.Col(imageIDColumn), + }, tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, repo: r, @@ -236,18 +242,18 @@ func (qb *ImageStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileIDs []models.FileID) error { +func (qb *ImageStore) Create(ctx context.Context, newObject *models.CreateImageInput) error { var r imageRow - r.fromImage(*newObject) + r.fromImage(*newObject.Image) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -276,12 +282,18 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI } } + if err := qb.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: newObject.CustomFields, + }); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Image = *updated return nil } @@ -329,6 +341,10 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index b56ade26d..aafd2aa40 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -100,6 +100,13 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, + &customFieldsFilterHandler{ + table: imagesCustomFieldsTable.GetTable(), + fkCol: imageIDColumn, + c: imageFilter.CustomFields, + idCol: "images.id", + }, + &relatedFilterHandler{ relatedIDCol: "galleries_images.gallery_id", relatedRepo: galleryRepository.repository, diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index aa4ed3b99..3bad40b3b 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -73,81 +73,94 @@ func Test_imageQueryBuilder_Create(t *testing.T) { tests := []struct { name string - newObject models.Image + newObject models.CreateImageInput wantErr bool }{ { "full", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, + CustomFields: testCustomFields, }, false, }, { "with file", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedFiles([]models.File{ - imageFile.(*models.ImageFile), - }), - PrimaryFileID: &imageFile.Base().ID, - Path: imageFile.Base().Path, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + Files: models.NewRelatedFiles([]models.File{ + imageFile.(*models.ImageFile), + }), + PrimaryFileID: &imageFile.Base().ID, + Path: imageFile.Base().Path, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, }, false, }, { "invalid studio id", - models.Image{ - StudioID: &invalidID, + models.CreateImageInput{ + Image: &models.Image{ + StudioID: &invalidID, + }, }, true, }, { "invalid gallery id", - models.Image{ - GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid tag id", - models.Image{ - TagIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid performer id", - models.Image{ - PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, @@ -165,8 +178,11 @@ func Test_imageQueryBuilder_Create(t *testing.T) { fileIDs = append(fileIDs, f.Base().ID) } } - s := tt.newObject - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + s := *tt.newObject.Image + if err := qb.Create(ctx, &models.CreateImageInput{ + Image: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -177,7 +193,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { assert.NotZero(s.ID) - copy := tt.newObject + copy := *tt.newObject.Image copy.ID = s.ID // load relationships @@ -201,8 +217,6 @@ func Test_imageQueryBuilder_Create(t *testing.T) { } assert.Equal(copy, *found) - - return }) } } @@ -387,8 +401,6 @@ func Test_imageQueryBuilder_Update(t *testing.T) { } assert.Equal(copy, *s) - - return }) } } @@ -832,6 +844,79 @@ func Test_imageQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_ImageStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.ImagePartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + imageIDs[imageIdxWithStudio], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Image + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("ImageStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("ImageStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_imageQueryBuilder_IncrementOCounter(t *testing.T) { tests := []struct { name string @@ -3018,6 +3103,252 @@ func TestImageQueryPagination(t *testing.T) { }) } +func TestImageQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not equals", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "includes", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "excludes", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{imageIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "between", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{imageIdx2WithGallery}, + nil, + false, + }, + { + "not between", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx2WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{imageIdx2WithGallery}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + result, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + images, err := result.Resolve(ctx) + if err != nil { + t.Errorf("ImageStore.Query().Resolve() error = %v", err) + } + + ids := imagesToIDs(images) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO SizeCount // TODO All diff --git a/pkg/sqlite/migrations/83_image_custom_fields.up.sql b/pkg/sqlite/migrations/83_image_custom_fields.up.sql new file mode 100644 index 000000000..0aa3aa4d7 --- /dev/null +++ b/pkg/sqlite/migrations/83_image_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `image_custom_fields` ( + `image_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`image_id`, `field`), + foreign key(`image_id`) references `images`(`id`) on delete CASCADE +); + +CREATE INDEX `index_image_custom_fields_field_value` ON `image_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 0078f1a67..2848a0a14 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1247,6 +1247,18 @@ func getImageBasename(index int) string { return getImageStringValue(index, pathField) } +func getImageCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getImageStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func makeImageFile(i int) *models.ImageFile { return &models.ImageFile{ BaseFile: &models.BaseFile{ @@ -1309,7 +1321,11 @@ func createImages(ctx context.Context, n int) error { image := makeImage(i) - err := qb.Create(ctx, image, []models.FileID{f.ID}) + err := qb.Create(ctx, &models.CreateImageInput{ + Image: image, + FileIDs: []models.FileID{f.ID}, + CustomFields: getImageCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating image %v+: %s", image, err.Error()) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 6c898048d..4c09113f0 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -14,6 +14,7 @@ var ( performersImagesJoinTable = goqu.T(performersImagesTable) imagesFilesJoinTable = goqu.T(imagesFilesTable) imagesURLsJoinTable = goqu.T(imagesURLsTable) + imagesCustomFieldsTable = goqu.T("image_custom_fields") galleriesFilesJoinTable = goqu.T(galleriesFilesTable) galleriesTagsJoinTable = goqu.T(galleriesTagsTable) From 410dd27d93bd3fce553902f334eccde0311f17da Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:54:20 +1100 Subject: [PATCH 006/152] Fix misclicks resulting in navigating to new page during selection (#6599) * Disable studio overlay link if selecting * Prevent scene preview scrubber click navigating during selection * Prevent gallery preview scrubber click navigating during selection --- .../src/components/Galleries/GalleryCard.tsx | 17 +++++++++++++++-- .../Galleries/GalleryPreviewScrubber.tsx | 3 +++ ui/v2.5/src/components/Images/ImageCard.tsx | 8 +++++++- .../src/components/Scenes/PreviewScrubber.tsx | 3 +++ ui/v2.5/src/components/Scenes/SceneCard.tsx | 18 ++++++++++++++++-- .../Shared/GridCard/StudioOverlay.tsx | 11 +++++++++-- .../src/components/Shared/HoverScrubber.tsx | 7 +++++++ 7 files changed, 60 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index e4e227f3e..01e0b6045 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -1,5 +1,5 @@ import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -21,11 +21,13 @@ import { PatchComponent } from "src/patch"; interface IGalleryPreviewProps { gallery: GQL.SlimGalleryDataFragment; onScrubberClick?: (index: number) => void; + disabled?: boolean; } export const GalleryPreview: React.FC = ({ gallery, onScrubberClick, + disabled, }) => { const [imgSrc, setImgSrc] = useState( gallery.paths.cover ?? undefined @@ -48,6 +50,7 @@ export const GalleryPreview: React.FC = ({ imageCount={gallery.image_count} onClick={onScrubberClick} onPathChanged={setImgSrc} + disabled={disabled} /> )} @@ -195,7 +198,16 @@ const GalleryCardDetails = PatchComponent( const GalleryCardOverlays = PatchComponent( "GalleryCard.Overlays", (props: IGalleryCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.gallery.studio, props.selecting]); + + return ret; } ); @@ -211,6 +223,7 @@ const GalleryCardImage = PatchComponent( onScrubberClick={(i) => { history.push(`/galleries/${props.gallery.id}/images/${i}`); }} + disabled={props.selecting} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx index ef47782bf..5c0a07356 100644 --- a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -10,6 +10,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount: number; onClick?: (imageIndex: number) => void; onPathChanged: React.Dispatch>; + disabled?: boolean; }> = ({ className, previewPath, @@ -17,6 +18,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount, onClick, onPathChanged, + disabled, }) => { const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); @@ -48,6 +50,7 @@ export const GalleryPreviewScrubber: React.FC<{ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} /> ); diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index adaee9923..a1189c844 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -148,7 +148,13 @@ const ImageCardDetails = PatchComponent( const ImageCardOverlays = PatchComponent( "ImageCard.Overlays", (props: IImageCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.image.studio, props.selecting]); + + return ret; } ); diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 8ecb6e557..8c9d3097d 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -13,6 +13,7 @@ import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; onClick?: (timestamp: number) => void; + disabled?: boolean; } function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { @@ -32,6 +33,7 @@ const defaultSprites = 81; // 9x9 grid by default export const PreviewScrubber: React.FC = ({ vttPath, onClick, + disabled, }) => { const imageParentRef = useRef(null); const [style, setStyle] = useState({}); @@ -113,6 +115,7 @@ export const PreviewScrubber: React.FC = ({ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 2cb4a9af3..b7c263168 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -38,6 +38,7 @@ interface IScenePreviewProps { soundActive: boolean; vttPath?: string; onScrubberClick?: (timestamp: number) => void; + disabled?: boolean; } export const ScenePreview: React.FC = ({ @@ -47,6 +48,7 @@ export const ScenePreview: React.FC = ({ soundActive, vttPath, onScrubberClick, + disabled, }) => { const videoEl = useRef(null); @@ -86,7 +88,11 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> - + ); }; @@ -336,7 +342,13 @@ const SceneCardDetails = PatchComponent( const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.scene.studio, props.selecting]); + + return ret; } ); @@ -390,6 +402,7 @@ const SceneCardImage = PatchComponent( } function onScrubberClick(timestamp: number) { + if (props.selecting) return; const link = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, @@ -416,6 +429,7 @@ const SceneCardImage = PatchComponent( soundActive={configuration?.interface?.soundOnPreview ?? false} vttPath={props.scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} + disabled={props.selecting} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx index 9bfd25071..6fe07a454 100644 --- a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx @@ -10,7 +10,8 @@ interface IStudio { export const StudioOverlay: React.FC<{ studio: IStudio | null | undefined; -}> = ({ studio }) => { + disabled?: boolean; +}> = ({ studio, disabled }) => { const { configuration } = useConfigurationContext(); const configValue = configuration?.interface.showStudioAsText; @@ -29,12 +30,18 @@ export const StudioOverlay: React.FC<{ return false; }, [configValue, studio?.image_path]); + function onClick(e: React.MouseEvent) { + if (disabled) { + e.preventDefault(); + } + } + if (!studio) return <>; return ( // this class name is incorrect
- + {showStudioAsText ? ( studio.name ) : ( diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index 7c07e8adc..17c0ed79e 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -9,6 +9,7 @@ interface IHoverScrubber { activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; onClick?: (index: number) => void; + disabled?: boolean; } export const HoverScrubber: React.FC = ({ @@ -16,6 +17,7 @@ export const HoverScrubber: React.FC = ({ activeIndex, setActiveIndex, onClick, + disabled, }) => { function getActiveIndex( e: @@ -69,6 +71,11 @@ export const HoverScrubber: React.FC = ({ | React.TouchEvent ) { if (!onClick) return; + if (disabled) { + // allow propagation up so that selection still works + e.preventDefault(); + return; + } const relatedTarget = e.currentTarget; From 14105a2d54d77324ad90a994f586bd5d0705a14b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:54:40 +1100 Subject: [PATCH 007/152] Rename checksum and hash fields (#6600) Checksum -> MD5 Checksum Hash -> oshash with hover showing OpenSubtitles Hash. Also internationalised perceptual hash hover text. --- .../Images/ImageDetails/ImageFileInfoPanel.tsx | 7 ++++--- .../Scenes/SceneDetails/SceneFileInfoPanel.tsx | 11 ++++++++--- ui/v2.5/src/locales/en-GB.json | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index c346cf874..097a64340 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; -import { FormattedMessage, FormattedTime } from "react-intl"; +import { FormattedMessage, FormattedTime, useIntl } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; @@ -24,6 +24,7 @@ interface IFileInfoPanelProps { const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { + const intl = useIntl(); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); @@ -38,10 +39,10 @@ const FileInfoPanel: React.FC = ( )} - + = ( )} - - + + Date: Tue, 24 Feb 2026 16:39:14 -0800 Subject: [PATCH 008/152] FR: Tags Tagger (#6559) * Refactor Tagger components * condense localization * add alias and description to model and schema --- graphql/schema/schema.graphql | 2 + graphql/schema/types/scraper.graphql | 2 + graphql/stash-box/query.graphql | 2 + internal/api/resolver_mutation_stash_box.go | 10 + internal/manager/manager_tasks.go | 130 +++ internal/manager/task_stash_box_tag.go | 173 ++++ pkg/models/mocks/TagReaderWriter.go | 23 + pkg/models/model_scraped_item.go | 52 +- pkg/models/repository_tag.go | 1 + pkg/sqlite/tag.go | 30 + pkg/stashbox/graphql/generated_client.go | 28 +- pkg/stashbox/tag.go | 25 +- ui/v2.5/graphql/data/scrapers.graphql | 2 + ui/v2.5/graphql/mutations/stash-box.graphql | 4 + .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 8 +- ...merFieldSelector.tsx => FieldSelector.tsx} | 13 +- .../Config.tsx => TaggerConfig.tsx} | 51 +- ui/v2.5/src/components/Tagger/constants.ts | 4 + .../Tagger/performers/PerformerTagger.tsx | 12 +- ui/v2.5/src/components/Tagger/queries.ts | 41 + .../src/components/Tagger/studios/Config.tsx | 130 --- .../Tagger/studios/StudioFieldSelector.tsx | 68 -- .../Tagger/studios/StudioTagger.tsx | 34 +- .../Tagger/tags/StashSearchResult.tsx | 119 +++ .../src/components/Tagger/tags/TagModal.tsx | 144 ++++ .../src/components/Tagger/tags/TagTagger.tsx | 758 ++++++++++++++++++ ui/v2.5/src/components/Tagger/utils.ts | 30 +- ui/v2.5/src/components/Tags/TagList.tsx | 4 + ui/v2.5/src/core/StashService.ts | 6 + ui/v2.5/src/locales/en-GB.json | 52 +- ui/v2.5/src/models/list-filter/tags.ts | 6 +- 31 files changed, 1702 insertions(+), 262 deletions(-) rename ui/v2.5/src/components/Tagger/{PerformerFieldSelector.tsx => FieldSelector.tsx} (84%) rename ui/v2.5/src/components/Tagger/{performers/Config.tsx => TaggerConfig.tsx} (69%) delete mode 100644 ui/v2.5/src/components/Tagger/studios/Config.tsx delete mode 100644 ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/TagModal.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/TagTagger.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 996afefe7..7f07e4579 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -583,6 +583,8 @@ type Mutation { stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! "Run batch studio tag task. Returns the job ID." stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! + "Run batch tag tag task. Returns the job ID." + stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String! "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" enableDLNA(input: EnableDLNAInput!): Boolean! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 9c0e33fdf..b8810aa79 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -71,6 +71,8 @@ type ScrapedTag { "Set if tag matched" stored_id: ID name: String! + description: String + alias_list: [String!] "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index e2686ac4d..edd44c835 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -29,6 +29,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment MeasurementsFragment on Measurements { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 436937511..6d2ab84fd 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man return strconv.Itoa(jobID), nil } +func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { + b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck + if err != nil { + return "", err + } + + jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input) + return strconv.Itoa(jobID), nil +} + func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index bac726c1b..e97227fcf 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -704,3 +704,133 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) } + +func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + + for _, tagID := range input.Ids { + if id, err := strconv.Atoi(tagID); err == nil { + t, err := tagQuery.Find(ctx, id) + if err != nil { + return err + } + + if err := t.LoadStashIDs(ctx, tagQuery); err != nil { + return fmt.Errorf("loading tag stash ids: %w", err) + } + + hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil + if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + } + return nil + }) + + return tasks, err +} + +func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { + var tasks []Task + + for i := range input.StashIDs { + stashID := input.StashIDs[i] + if len(stashID) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + stashID: &stashID, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + for i := range input.Names { + name := input.Names[i] + if len(name) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + name: &name, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + return tasks +} + +func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + var tags []*models.Tag + var err error + + tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) + + if err != nil { + return fmt.Errorf("error querying tags: %v", err) + } + + for _, t := range tags { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + box: box, + excludedFields: input.ExcludeFields, + }) + } + return nil + }) + + return tasks, err +} + +func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { + j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { + logger.Infof("Initiating stash-box batch tag tag") + + var tasks []Task + var err error + + switch input.getBatchTagType(false) { + case batchTagByIds: + tasks, err = s.batchTagTagsByIds(ctx, input, box) + case batchTagByNamesOrStashIds: + tasks = s.batchTagTagsByNamesOrStashIds(input, box) + case batchTagAll: + tasks, err = s.batchTagAllTags(ctx, input, box) + } + + if err != nil { + return err + } + + if len(tasks) == 0 { + return nil + } + + progress.SetTotal(len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d tags", len(tasks)) + + for _, task := range tasks { + progress.ExecuteTask(task.GetDescription(), func() { + task.Start(ctx) + }) + + progress.Increment() + } + + return nil + }) + + return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j) +} diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 4848b46ad..97c766010 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -12,6 +12,7 @@ import ( "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/tag" ) // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box. @@ -529,3 +530,175 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa return err } } + +// stashBoxBatchTagTagTask is used to tag or create tags from stash-box. +// +// Two modes of operation: +// - Update existing tag: set tag to update from stash-box data +// - Create new tag: set name or stashID to search stash-box and create locally +type stashBoxBatchTagTagTask struct { + box *models.StashBox + name *string + stashID *string + tag *models.Tag + excludedFields []string +} + +func (t *stashBoxBatchTagTagTask) getName() string { + switch { + case t.name != nil: + return *t.name + case t.stashID != nil: + return *t.stashID + case t.tag != nil: + return t.tag.Name + default: + return "" + } +} + +func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) { + scrapedTag, err := t.findStashBoxTag(ctx) + if err != nil { + logger.Errorf("Error fetching tag data from stash-box: %v", err) + return + } + + excluded := map[string]bool{} + for _, field := range t.excludedFields { + excluded[field] = true + } + + if scrapedTag != nil { + t.processMatchedTag(ctx, scrapedTag, excluded) + } else { + logger.Infof("No match found for %s", t.getName()) + } +} + +func (t *stashBoxBatchTagTagTask) GetDescription() string { + return fmt.Sprintf("Tagging tag %s from stash-box", t.getName()) +} + +func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) { + var results []*models.ScrapedTag + var err error + + r := instance.Repository + + client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) + + switch { + case t.name != nil: + results, err = client.QueryTag(ctx, *t.name) + case t.stashID != nil: + results, err = client.QueryTag(ctx, *t.stashID) + case t.tag != nil: + var remoteID string + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + if !t.tag.StashIDs.Loaded() { + err = t.tag.LoadStashIDs(ctx, r.Tag) + if err != nil { + return err + } + } + for _, id := range t.tag.StashIDs.List() { + if id.Endpoint == t.box.Endpoint { + remoteID = id.StashID + } + } + return nil + }); err != nil { + return nil, err + } + + if remoteID != "" { + results, err = client.QueryTag(ctx, remoteID) + } else { + results, err = client.QueryTag(ctx, t.tag.Name) + } + } + + if err != nil { + return nil, err + } + + if len(results) == 0 { + return nil, nil + } + + result := results[0] + + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint) + }); err != nil { + return nil, err + } + + return result, nil +} + +func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { + // Determine the tag ID to update — either from the task's tag or from the + // StoredID set by match.ScrapedTag (when batch adding by name and the tag + // already exists locally). + tagID := 0 + if t.tag != nil { + tagID = t.tag.ID + } else if s.StoredID != nil { + tagID, _ = strconv.Atoi(*s.StoredID) + } + + if tagID > 0 { + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + existingStashIDs, err := qb.GetStashIDs(ctx, tagID) + if err != nil { + return err + } + + storedID := strconv.Itoa(tagID) + partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs) + + if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to update tag %s: %v", s.Name, err) + } else { + logger.Infof("Updated tag %s", s.Name) + } + } else if s.Name != "" { + // no existing tag, create a new one + newTag := s.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to create tag %s: %v", s.Name, err) + } else { + logger.Infof("Created tag %s", s.Name) + } + } +} diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 95a3b7a87..c4423ee52 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -450,6 +450,29 @@ func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.Sta return r0, r1 } +// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint +func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + ret := _m.Called(ctx, hasStashID, stashboxEndpoint) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok { + r0 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { + r1 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 3c0e083c1..1367003cb 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -471,9 +471,11 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + Description *string `json:"description"` + AliasList []string `json:"alias_list"` + RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedTag) IsScrapedContent() {} @@ -482,6 +484,17 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { currentTime := time.Now() ret := NewTag() ret.Name = t.Name + ret.ParentIDs = NewRelatedIDs([]int{}) + ret.ChildIDs = NewRelatedIDs([]int{}) + ret.Aliases = NewRelatedStrings([]string{}) + + if t.Description != nil && !excluded["description"] { + ret.Description = *t.Description + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = NewRelatedStrings(t.AliasList) + } if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ @@ -496,6 +509,39 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { return &ret } +func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial { + ret := NewTagPartial() + + if t.Name != "" && !excluded["name"] { + ret.Name = NewOptionalString(t.Name) + } + + if t.Description != nil && !excluded["description"] { + ret.Description = NewOptionalString(*t.Description) + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = &UpdateStrings{ + Values: t.AliasList, + Mode: RelationshipUpdateModeSet, + } + } + + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { + ret.StashIDs = &UpdateStashIDs{ + StashIDs: existingStashIDs, + Mode: RelationshipUpdateModeSet, + } + ret.StashIDs.Set(StashID{ + Endpoint: endpoint, + StashID: *t.RemoteSiteID, + UpdatedAt: time.Now(), + }) + } + + return ret +} + func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index ba403cf2d..02dfe0cb6 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -26,6 +26,7 @@ type TagFinder interface { FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) + FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error) } // TagQueryer provides methods to query tags. diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a926dd56e..750836516 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -597,6 +597,36 @@ func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ( return ret, nil } +func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + table := qb.table() + sq := dialect.From(table).LeftJoin( + tagsStashIDsJoinTable, + goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))), + ).Select(table.Col(idColumn)) + + if hasStashID { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNotNull(), + tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), + ) + } else { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNull(), + ) + } + + idsQuery := qb.selectDataset().Where( + table.Col(idColumn).In(sq), + ) + + ret, err := qb.getMany(ctx, idsQuery) + if err != nil { + return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err) + } + + return ret, nil +} + func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 29b702a7f..acb2202dc 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -128,8 +128,10 @@ func (t *StudioFragment) GetImages() []*ImageFragment { } type TagFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" } func (t *TagFragment) GetName() string { @@ -144,6 +146,18 @@ func (t *TagFragment) GetID() string { } return t.ID } +func (t *TagFragment) GetDescription() *string { + if t == nil { + t = &TagFragment{} + } + return t.Description +} +func (t *TagFragment) GetAliases() []string { + if t == nil { + t = &TagFragment{} + } + return t.Aliases +} type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" @@ -849,6 +863,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -985,6 +1001,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1279,6 +1297,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1413,6 +1433,8 @@ const FindTagDocument = `query FindTag ($id: ID, $name: String) { fragment TagFragment on Tag { name id + description + aliases } ` @@ -1445,6 +1467,8 @@ const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { fragment TagFragment on Tag { name id + description + aliases } ` diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index df2ecbcc0..452dd9928 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -31,10 +31,8 @@ func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTa return nil, nil } - return []*models.ScrapedTag{{ - Name: tag.FindTag.Name, - RemoteSiteID: &tag.FindTag.ID, - }}, nil + ret := tagFragmentToScrapedTag(*tag.FindTag) + return []*models.ScrapedTag{ret}, nil } func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { @@ -57,11 +55,22 @@ func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.Scr var ret []*models.ScrapedTag for _, t := range result.QueryTags.Tags { - ret = append(ret, &models.ScrapedTag{ - Name: t.Name, - RemoteSiteID: &t.ID, - }) + ret = append(ret, tagFragmentToScrapedTag(*t)) } return ret, nil } + +func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { + ret := &models.ScrapedTag{ + Name: t.Name, + Description: t.Description, + RemoteSiteID: &t.ID, + } + + if len(t.Aliases) > 0 { + ret.AliasList = t.Aliases + } + + return ret +} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index e58c21a20..7214c2064 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -160,6 +160,8 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + description + alias_list remote_site_id } diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index 596dc4302..de5f5136c 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -12,6 +12,10 @@ mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) { stashBoxBatchStudioTag(input: $input) } +mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) { + stashBoxBatchTagTag(input: $input) +} + mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxSceneDraft(input: $input) } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index f383f245a..6bd535df7 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -395,7 +395,13 @@ export const ScrapedTagsRow: React.FC< onSelect={(items) => { if (onChangeFn) { // map the id back to stored_id - onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); + onChangeFn( + items.map((p) => ({ + ...p, + stored_id: p.id, + alias_list: p.aliases, + })) + ); } }} ids={selectValue} diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/FieldSelector.tsx similarity index 84% rename from ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx rename to ui/v2.5/src/components/Tagger/FieldSelector.tsx index b50716511..7a47862b5 100644 --- a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx +++ b/ui/v2.5/src/components/Tagger/FieldSelector.tsx @@ -5,22 +5,25 @@ import { useIntl } from "react-intl"; import { ModalComponent } from "../Shared/Modal"; import { Icon } from "../Shared/Icon"; -import { PERFORMER_FIELDS } from "./constants"; interface IProps { show: boolean; + fields: string[]; excludedFields: string[]; onSelect: (fields: string[]) => void; } -const PerformerFieldSelect: React.FC = ({ +const FieldSelector: React.FC = ({ show, + fields, excludedFields, onSelect, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( - excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + excludedFields + .filter((field) => fields.includes(field)) + .reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleField = (field: string) => @@ -57,9 +60,9 @@ const PerformerFieldSelect: React.FC = ({
These fields will be tagged by default. Click the button to toggle.
- {PERFORMER_FIELDS.map((f) => renderField(f))} + {fields.map((f) => renderField(f))} ); }; -export default PerformerFieldSelect; +export default FieldSelector; diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx similarity index 69% rename from ui/v2.5/src/components/Tagger/performers/Config.tsx rename to ui/v2.5/src/components/Tagger/TaggerConfig.tsx index 0d5316735..c578d58c4 100644 --- a/ui/v2.5/src/components/Tagger/performers/Config.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx @@ -3,21 +3,33 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { useConfigurationContext } from "src/hooks/Config"; -import { ITaggerConfig } from "../constants"; -import PerformerFieldSelector from "../PerformerFieldSelector"; +import { ITaggerConfig } from "./constants"; +import FieldSelector from "./FieldSelector"; -interface IConfigProps { +interface ITaggerConfigProps { show: boolean; config: ITaggerConfig; setConfig: Dispatch; + excludedFields: string[]; + onFieldsChange: (fields: string[]) => void; + fields: string[]; + entityName: string; + extraConfig?: React.ReactNode; } -const Config: React.FC = ({ show, config, setConfig }) => { +const TaggerConfig: React.FC = ({ + show, + config, + setConfig, + excludedFields, + onFieldsChange, + fields, + entityName, + extraConfig, +}) => { const { configuration: stashConfig } = useConfigurationContext(); const [showExclusionModal, setShowExclusionModal] = useState(false); - const excludedFields = config.excludedPerformerFields ?? []; - const handleInstanceSelect = (e: React.ChangeEvent) => { const selectedEndpoint = e.currentTarget.value; setConfig({ @@ -28,8 +40,8 @@ const Config: React.FC = ({ show, config, setConfig }) => { const stashBoxes = stashConfig?.general.stashBoxes ?? []; - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedPerformerFields: fields }); + const handleFieldSelect = (selectedFields: string[]) => { + onFieldsChange(selectedFields); setShowExclusionModal(false); }; @@ -43,9 +55,10 @@ const Config: React.FC = ({ show, config, setConfig }) => {
- + {extraConfig} +
- +
{excludedFields.length > 0 ? ( @@ -55,17 +68,20 @@ const Config: React.FC = ({ show, config, setConfig }) => { )) ) : ( - + )} - +
= ({ show, config, setConfig }) => { className="align-items-center row no-gutters mt-4" > - + = ({ show, config, setConfig }) => { > {!stashBoxes.length && ( )} {stashConfig?.general.stashBoxes.map((i) => ( @@ -98,8 +114,9 @@ const Config: React.FC = ({ show, config, setConfig }) => {
- @@ -107,4 +124,4 @@ const Config: React.FC = ({ show, config, setConfig }) => { ); }; -export default Config; +export default TaggerConfig; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d59a6d3d5..af9afcefb 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -24,6 +24,7 @@ export const DEFAULT_BLACKLIST = [ ]; export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"]; +export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, @@ -35,6 +36,7 @@ export const initialConfig: ITaggerConfig = { excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, + excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, }; @@ -52,6 +54,7 @@ export interface ITaggerConfig { excludedPerformerFields?: string[]; markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; + excludedTagFields?: string[]; createParentStudios: boolean; } @@ -82,3 +85,4 @@ export const PERFORMER_FIELDS = [ ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; +export const TAG_FIELDS = ["name", "description", "aliases"]; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index bb934a241..8106d6a44 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -19,8 +19,8 @@ import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import PerformerConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; @@ -771,10 +771,16 @@ export const PerformerTagger: React.FC = ({ performers }) => {
- + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" /> { return updateStudioHandler; }; + +export const useUpdateTag = () => { + const [updateTag] = GQL.useTagUpdateMutation({ + onError: (errors) => errors, + errorPolicy: "all", + }); + + const updateTagHandler = (input: GQL.TagUpdateInput) => + updateTag({ + variables: { + input, + }, + update: (store, updatedTag) => { + if (!updatedTag.data?.tagUpdate) return; + + updatedTag.data.tagUpdate.stash_ids.forEach((id) => { + store.writeQuery({ + query: GQL.FindTagsDocument, + variables: { + tag_filter: { + stash_id_endpoint: { + stash_id: id.stash_id, + endpoint: id.endpoint, + modifier: GQL.CriterionModifier.Equals, + }, + }, + }, + data: { + findTags: { + count: 1, + tags: [updatedTag.data!.tagUpdate!], + __typename: "FindTagsResultType", + }, + }, + }); + }); + }, + }); + + return updateTagHandler; +}; diff --git a/ui/v2.5/src/components/Tagger/studios/Config.tsx b/ui/v2.5/src/components/Tagger/studios/Config.tsx deleted file mode 100644 index ddfd17b1e..000000000 --- a/ui/v2.5/src/components/Tagger/studios/Config.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { Dispatch, useState } from "react"; -import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; -import { useConfigurationContext } from "src/hooks/Config"; - -import { ITaggerConfig } from "../constants"; -import StudioFieldSelector from "./StudioFieldSelector"; - -interface IConfigProps { - show: boolean; - config: ITaggerConfig; - setConfig: Dispatch; -} - -const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = useConfigurationContext(); - const [showExclusionModal, setShowExclusionModal] = useState(false); - - const excludedFields = config.excludedStudioFields ?? []; - - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedStudioFields: fields }); - setShowExclusionModal(false); - }; - - return ( - <> - - -
-

- -

-
-
- - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - -
- -
- - {excludedFields.length > 0 ? ( - excludedFields.map((f) => ( - - - - )) - ) : ( - - )} - - - - - -
- - - - - - {!stashBoxes.length && ( - - )} - {stashConfig?.general.stashBoxes.map((i) => ( - - ))} - - -
-
-
-
- - - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx b/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx deleted file mode 100644 index 658f23510..000000000 --- a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; -import { Button, Row, Col } from "react-bootstrap"; -import { useIntl } from "react-intl"; - -import { ModalComponent } from "../../Shared/Modal"; -import { Icon } from "../../Shared/Icon"; -import { STUDIO_FIELDS } from "../constants"; - -interface IProps { - show: boolean; - excludedFields: string[]; - onSelect: (fields: string[]) => void; -} - -const StudioFieldSelect: React.FC = ({ - show, - excludedFields, - onSelect, -}) => { - const intl = useIntl(); - const [excluded, setExcluded] = useState>( - // filter out fields that aren't in STUDIO_FIELDS - excludedFields - .filter((field) => STUDIO_FIELDS.includes(field)) - .reduce((dict, field) => ({ ...dict, [field]: true }), {}) - ); - - const toggleField = (field: string) => - setExcluded({ - ...excluded, - [field]: !excluded[field], - }); - - const renderField = (field: string) => ( - - - {intl.formatMessage({ id: field })} - - ); - - return ( - - onSelect(Object.keys(excluded).filter((f) => excluded[f])), - }} - > -

Select tagged fields

-
- These fields will be tagged by default. Click the button to toggle. -
- {STUDIO_FIELDS.map((f) => renderField(f))} -
- ); -}; - -export default StudioFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index ed9570431..64bb99b72 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -20,8 +20,8 @@ import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import StudioConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; @@ -825,10 +825,38 @@ export const StudioTagger: React.FC = ({ studios }) => { - + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } /> & + Partial> + ) => void; + excludedTagFields: string[]; +} + +const StashSearchResult: React.FC = ({ + tag, + stashboxTags, + onTagTagged, + excludedTagFields, + endpoint, +}) => { + const intl = useIntl(); + + const [modalTag, setModalTag] = useState(); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const updateTag = useUpdateTag(); + + const handleSave = async (input: GQL.TagCreateInput) => { + setError({}); + setModalTag(undefined); + setSaveState("Saving tag"); + + const updateData: GQL.TagUpdateInput = { + ...input, + id: tag.id, + }; + + updateData.stash_ids = await mergeTagStashIDs( + tag.id, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + + if (!res?.data?.tagUpdate) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: input.name ?? tag.name } + ), + details: + res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : res?.errors?.[0]?.message ?? "", + }); + } else { + onTagTagged(tag); + } + setSaveState(""); + }; + + const tags = stashboxTags.map((p) => ( + + )); + + return ( + <> + {modalTag && ( + setModalTag(undefined)} + modalVisible={modalTag !== undefined} + tag={modalTag} + onSave={handleSave} + icon={faTags} + header="Update Tag" + excludedTagFields={excludedTagFields} + endpoint={endpoint} + /> + )} +
{tags}
+
+ {error.message && ( +
+ + Error: + {error.message} + +
{error.details}
+
+ )} + {saveState && ( + {saveState} + )} +
+ + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx new file mode 100644 index 000000000..1183d8f0c --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -0,0 +1,144 @@ +import React, { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; + +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "src/components/Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { + faCheck, + faExternalLinkAlt, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; +import { Button } from "react-bootstrap"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { excludeFields } from "src/utils/data"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; + +interface ITagModalProps { + tag: GQL.ScrapedSceneTagDataFragment; + modalVisible: boolean; + closeModal: () => void; + onSave: (input: GQL.TagCreateInput) => void; + excludedTagFields?: string[]; + header: string; + icon: IconDefinition; + endpoint?: string; +} + +const TagModal: React.FC = ({ + modalVisible, + tag, + onSave, + closeModal, + excludedTagFields = [], + header, + icon, + endpoint, +}) => { + const intl = useIntl(); + + const [excluded, setExcluded] = useState>( + excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + function maybeRenderField(id: string, text: string | null | undefined) { + if (!text) return; + + return ( +
+
+ + + : + +
+ +
+ ); + } + + function maybeRenderStashBoxLink() { + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; + + if (!link) return; + + return ( +
+ + + + +
+ ); + } + + function handleSave() { + if (!tag.name) { + throw new Error("tag name must be set"); + } + + const tagData: GQL.TagCreateInput = { + name: tag.name, + description: tag.description ?? undefined, + aliases: tag.alias_list?.filter((a) => a) ?? undefined, + }; + + // stashid handling code + const remoteSiteID = tag.remote_site_id; + if (remoteSiteID && endpoint) { + tagData.stash_ids = [ + { + endpoint, + stash_id: remoteSiteID, + updated_at: new Date().toISOString(), + }, + ]; + } + + // handle exclusions + excludeFields(tagData, excluded); + + onSave(tagData); + } + + return ( + closeModal(), variant: "secondary" }} + onHide={() => closeModal()} + dialogClassName="studio-create-modal" + icon={icon} + header={header} + > +
+
+
+ {maybeRenderField("name", tag.name)} + {maybeRenderField("description", tag.description)} + {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderStashBoxLink()} +
+
+
+
+ ); +}; + +export default TagModal; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx new file mode 100644 index 000000000..1113bdfd4 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -0,0 +1,758 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { + stashBoxTagQuery, + useJobsSubscribe, + mutateStashBoxBatchTagTag, + getClient, +} from "src/core/StashService"; +import { Manual } from "src/components/Help/Manual"; +import { useConfigurationContext } from "src/hooks/Config"; + +import StashSearchResult from "./StashSearchResult"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, TAG_FIELDS } from "../constants"; +import { useUpdateTag } from "../queries"; +import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { mergeTagStashIDs } from "../utils"; +import { separateNamesAndStashIds } from "src/utils/stashIds"; +import { useTaggerConfig } from "../config"; + +type JobFragment = Pick< + GQL.Job, + "id" | "status" | "subTasks" | "description" | "progress" +>; + +const CLASSNAME = "StudioTagger"; + +interface ITagBatchUpdateModal { + tags: GQL.TagListDataFragment[]; + isIdle: boolean; + selectedEndpoint: { endpoint: string; index: number }; + onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; + close: () => void; +} + +const TagBatchUpdateModal: React.FC = ({ + tags, + isIdle, + selectedEndpoint, + onBatchUpdate, + close, +}) => { + const intl = useIntl(); + + const [queryAll, setQueryAll] = useState(false); + + const [refresh, setRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: refresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + + const tagCount = useMemo(() => { + const filteredStashIDs = tags.map((t) => + t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) + ); + + return queryAll + ? allTags?.findTags.count + : filteredStashIDs.filter((s) => + refresh ? s.length > 0 : s.length === 0 + ).length; + }, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]); + + return ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
+ +
+
+ } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
+ + +
+ +
+
+ setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
+ + + +
+ ); +}; + +interface ITagBatchAddModal { + isIdle: boolean; + onBatchAdd: (input: string) => void; + close: () => void; +} + +const TagBatchAddModal: React.FC = ({ + isIdle, + onBatchAdd, + close, +}) => { + const intl = useIntl(); + + const tagInput = useRef(null); + + return ( + { + if (tagInput.current) { + onBatchAdd(tagInput.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + + + ); +}; + +interface ITagTaggerListProps { + tags: GQL.TagListDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + isIdle: boolean; + config: ITaggerConfig; + onBatchAdd: (tagInput: string) => void; + onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; +} + +const TagTaggerList: React.FC = ({ + tags, + selectedEndpoint, + isIdle, + config, + onBatchAdd, + onBatchUpdate, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedTags, setTaggedTags] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + + const doBoxSearch = (tagID: string, searchVal: string) => { + stashBoxTagQuery(searchVal, selectedEndpoint.endpoint) + .then((queryData) => { + const s = queryData.data?.scrapeSingleTag ?? []; + setSearchResults({ + ...searchResults, + [tagID]: s, + }); + setSearchErrors({ + ...searchErrors, + [tagID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + const { [tagID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [tagID]: intl.formatMessage({ + id: "tag_tagger.network_error", + }), + }); + }); + + setLoading(true); + }; + + const updateTag = useUpdateTag(); + + const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => { + setLoadingUpdate(stashID); + setError({ + ...error, + [tagID]: undefined, + }); + stashBoxTagQuery(stashID, endpoint) + .then(async (queryData) => { + const data = queryData.data?.scrapeSingleTag ?? []; + if (data.length > 0) { + const stashboxTag = data[0]; + const updateData: GQL.TagUpdateInput = { + id: tagID, + }; + + if ( + !(config.excludedTagFields ?? []).includes("name") && + stashboxTag.name + ) { + updateData.name = stashboxTag.name; + } + + if ( + stashboxTag.description && + !(config.excludedTagFields ?? []).includes("description") + ) { + updateData.description = stashboxTag.description; + } + + if ( + stashboxTag.alias_list && + stashboxTag.alias_list.length > 0 && + !(config.excludedTagFields ?? []).includes("aliases") + ) { + updateData.aliases = stashboxTag.alias_list; + } + + if (stashboxTag.remote_site_id) { + updateData.stash_ids = await mergeTagStashIDs(tagID, [ + { + endpoint, + stash_id: stashboxTag.remote_site_id, + }, + ]); + } + + const res = await updateTag(updateData); + if (!res?.data?.tagUpdate) { + setError({ + ...error, + [tagID]: { + message: `Failed to update tag`, + details: res?.errors?.[0]?.message ?? "", + }, + }); + } + } + }) + .finally(() => setLoadingUpdate(undefined)); + }; + + async function handleBatchAdd(input: string) { + onBatchAdd(input); + setShowBatchAdd(false); + } + + const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { + onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh); + setShowBatchUpdate(false); + }; + + const handleTaggedTag = ( + tag: Pick & + Partial> + ) => { + setTaggedTags({ + ...taggedTags, + [tag.id]: tag, + }); + }; + + const renderTags = () => + tags.map((tag) => { + const isTagged = taggedTags[tag.id]; + + const stashID = tag.stash_ids.find((s) => { + return s.endpoint === selectedEndpoint.endpoint; + }); + + let mainContent; + if (!isTagged && stashID !== undefined) { + mainContent = ( +
+
+ +
+
+ ); + } else if (!isTagged && !stashID) { + mainContent = ( + + + setQueries({ + ...queries, + [tag.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "") + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
+
+ +
+
+ ); + } + + let subContent; + if (stashID !== undefined) { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + subContent = ( +
+ + {link} + + + + + {error[tag.id] && ( +
+ + Error: + {error[tag.id]?.message} + +
{error[tag.id]?.details}
+
+ )} +
+ ); + } else if (searchErrors[tag.id]) { + subContent = ( +
+ {searchErrors[tag.id]} +
+ ); + } else if (searchResults[tag.id]?.length === 0) { + subContent = ( +
+ +
+ ); + } + + let searchResult; + if (searchResults[tag.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
+
+
+
+ + + +
+
+ +

{tag.name}

+ + {mainContent} +
{subContent}
+ {searchResult} +
+
+
+ ); + }); + + return ( + + {showBatchUpdate && ( + setShowBatchUpdate(false)} + isIdle={isIdle} + selectedEndpoint={selectedEndpoint} + tags={tags} + onBatchUpdate={handleBatchUpdate} + /> + )} + + {showBatchAdd && ( + setShowBatchAdd(false)} + isIdle={isIdle} + onBatchAdd={handleBatchAdd} + /> + )} +
+ + +
+
{renderTags()}
+
+ ); +}; + +interface ITaggerProps { + tags: GQL.TagListDataFragment[]; +} + +export const TagTagger: React.FC = ({ tags }) => { + const jobsSubscribe = useJobsSubscribe(); + const intl = useIntl(); + const { configuration: stashConfig } = useConfigurationContext(); + const { config, setConfig } = useTaggerConfig(); + const [showConfig, setShowConfig] = useState(false); + const [showManual, setShowManual] = useState(false); + + const [batchJobID, setBatchJobID] = useState(); + const [batchJob, setBatchJob] = useState(); + + useEffect(() => { + if (!jobsSubscribe.data) { + return; + } + + const event = jobsSubscribe.data.jobsSubscribe; + if (event.job.id !== batchJobID) { + return; + } + + if (event.type !== GQL.JobStatusUpdateType.Remove) { + setBatchJob(event.job); + } else { + setBatchJob(undefined); + setBatchJobID(undefined); + + const ac = getClient(); + ac.cache.evict({ fieldName: "findTags" }); + ac.cache.gc(); + } + }, [jobsSubscribe, batchJobID]); + + if (!config) return ; + + const savedEndpointIndex = + stashConfig?.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig?.general.stashBoxes[selectedEndpointIndex]; + + async function batchAdd(tagInput: string) { + if (tagInput && selectedEndpoint) { + const inputs = tagInput + .split(",") + .map((n) => n.trim()) + .filter((n) => n.length > 0); + + const { names, stashIds } = separateNamesAndStashIds(inputs); + + if (names.length > 0 || stashIds.length > 0) { + const ret = await mutateStashBoxBatchTagTag({ + names: names.length > 0 ? names : undefined, + stash_ids: stashIds.length > 0 ? stashIds : undefined, + endpoint: selectedEndpointIndex, + refresh: false, + createParent: false, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + } + + async function batchUpdate(ids: string[] | undefined, refresh: boolean) { + if (selectedEndpoint) { + const ret = await mutateStashBoxBatchTagTag({ + ids: ids, + endpoint: selectedEndpointIndex, + refresh, + createParent: false, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + + function renderStatus() { + if (batchJob) { + const progress = + batchJob.progress !== undefined && batchJob.progress !== null + ? batchJob.progress * 100 + : undefined; + return ( + +
+ +
+ {progress !== undefined && ( + + )} +
+ ); + } + + if (batchJobID !== undefined) { + return ( + +
+ +
+
+ ); + } + } + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + <> + setShowManual(false)} + defaultActiveTab="Tagger.md" + /> + {renderStatus()} +
+ {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
+ + +
+ + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + /> + + + ) : ( +
+

+ +

+
+ Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
+
+ )} +
+ + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8c1cf54e5..cddad33d9 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -1,6 +1,6 @@ import * as GQL from "src/core/generated-graphql"; import { ParseMode } from "./constants"; -import { queryFindStudio } from "src/core/StashService"; +import { queryFindStudio, queryFindTag } from "src/core/StashService"; import { mergeStashIDs } from "src/utils/stashbox"; const months = [ @@ -173,14 +173,32 @@ export const parsePath = (filePath: string) => { return { paths, file, ext }; }; -export async function mergeStudioStashIDs( +async function mergeEntityStashIDs( + fetchExisting: (id: string) => Promise, id: string, newStashIDs: GQL.StashIdInput[] ) { - const existing = await queryFindStudio(id); - if (existing?.data?.findStudio?.stash_ids) { - return mergeStashIDs(existing.data.findStudio.stash_ids, newStashIDs); + const existing = await fetchExisting(id); + if (existing) { + return mergeStashIDs(existing, newStashIDs); } - return newStashIDs; } + +export const mergeStudioStashIDs = ( + id: string, + newStashIDs: GQL.StashIdInput[] +) => + mergeEntityStashIDs( + async (studioId) => + (await queryFindStudio(studioId))?.data?.findStudio?.stash_ids, + id, + newStashIDs + ); + +export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) => + mergeEntityStashIDs( + async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids, + id, + newStashIDs + ); diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index e30f6071b..61b81b727 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -30,6 +30,7 @@ import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; import { IItemListOperation } from "../List/FilteredListToolbar"; import { PatchComponent } from "src/patch"; +import { TagTagger } from "../Tagger/tags/TagTagger"; function getItems(result: GQL.FindTagsForListQueryResult) { return result?.data?.findTags?.tags ?? []; @@ -355,6 +356,9 @@ export const TagList: React.FC = PatchComponent( if (filter.displayMode === DisplayMode.Wall) { return

TODO

; } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } } return ( <> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d276806fc..27186d6e1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2463,6 +2463,12 @@ export const mutateStashBoxBatchStudioTag = ( variables: { input }, }); +export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) => + client.mutate({ + mutation: GQL.StashBoxBatchTagTagDocument, + variables: { input }, + }); + export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery(); export const queryScrapeGroupURL = (url: string) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 629f1ece8..b7d3e2894 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1345,14 +1345,6 @@ "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.", "batch_add_performers": "Batch Add Performers", "batch_update_performers": "Batch Update Performers", - "config": { - "active_stash-box_instance": "Active stash-box instance:", - "edit_excluded_fields": "Edit Excluded Fields", - "excluded_fields": "Excluded fields:", - "no_fields_are_excluded": "No fields are excluded", - "no_instances_found": "No instances found", - "these_fields_will_not_be_changed_when_updating_performers": "These fields will not be changed when updating performers." - }, "current_page": "Current page", "failed_to_save_performer": "Failed to save performer \"{performer}\"", "name_already_exists": "Name already exists", @@ -1555,14 +1547,8 @@ "batch_add_studios": "Batch Add Studios", "batch_update_studios": "Batch Update Studios", "config": { - "active_stash-box_instance": "Active stash-box instance:", "create_parent_desc": "Create missing parent studios, or tag and update data/image for existing parent studios with exact name matches", - "create_parent_label": "Create parent studios", - "edit_excluded_fields": "Edit Excluded Fields", - "excluded_fields": "Excluded fields:", - "no_fields_are_excluded": "No fields are excluded", - "no_instances_found": "No instances found", - "these_fields_will_not_be_changed_when_updating_studios": "These fields will not be changed when updating studios." + "create_parent_label": "Create parent studios" }, "create_or_tag_parent_studios": "Create missing or tag existing parent studios", "current_page": "Current page", @@ -1604,6 +1590,42 @@ "tag_count": "Tag Count", "tag_parent_tooltip": "Has parent tags", "tag_sub_tag_tooltip": "Has sub-tags", + "tag_tagger": { + "add_new_tags": "Add New Tags", + "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.", + "batch_add_tags": "Batch Add Tags", + "batch_update_tags": "Batch Update Tags", + "current_page": "Current page", + "failed_to_save_tag": "Failed to save tag \"{tag}\"", + "name_already_exists": "Name already exists", + "network_error": "Network Error", + "no_results_found": "No results found.", + "number_of_tags_will_be_processed": "{tag_count} tags will be processed", + "query_all_tags_in_the_database": "All tags in the database", + "refresh_tagged_tags": "Refresh tagged tags", + "refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.", + "status_tagging_job_queued": "Status: Tagging job queued", + "status_tagging_tags": "Status: Tagging tags", + "tag_already_tagged": "Tag already tagged", + "tag_names_or_stashids_separated_by_comma": "Tag names or StashIDs separated by comma", + "tag_selection": "Tag selection", + "tag_successfully_tagged": "Tag successfully tagged", + "tag_status": "Tag Status", + "to_use_the_tag_tagger": "To use the tag tagger a stash-box instance needs to be configured.", + "untagged_tags": "Untagged tags", + "update_tags": "Update Tags", + "updating_untagged_tags_description": "Updating untagged tags will try to match any tags that lack a stashid and update the metadata." + }, + "tagger": { + "config": { + "active_stash-box_instance": "Active stash-box instance:", + "edit_excluded_fields": "Edit Excluded Fields", + "excluded_fields": "Excluded fields:", + "fields_will_not_be_changed": "These fields will not be changed when updating {entity}.", + "no_fields_are_excluded": "No fields are excluded", + "no_instances_found": "No instances found" + } + }, "tags": "Tags", "tattoos": "Tattoos", "time": "Time", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index e2d4fbed4..39ce9ca39 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -50,7 +50,11 @@ const sortByOptions = ["name", "random", "scenes_duration"] }, ]); -const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; +const displayModeOptions = [ + DisplayMode.Grid, + DisplayMode.List, + DisplayMode.Tagger, +]; const criterionOptions = [ FavoriteTagCriterionOption, createMandatoryStringCriterionOption("name"), From cf04e854d62178a719909d97e79d0b4318790040 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:21:16 +1100 Subject: [PATCH 009/152] Fix missing message id changes from #6600 --- .../Galleries/GalleryDetails/GalleryFileInfoPanel.tsx | 2 +- ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx | 2 +- ui/v2.5/src/models/list-filter/galleries.ts | 2 +- ui/v2.5/src/models/list-filter/images.ts | 2 +- ui/v2.5/src/models/list-filter/scenes.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index e97146b91..b7dab09a0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -38,7 +38,7 @@ const FileInfoPanel: React.FC = ( )} - + diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index dc0a616d6..f39fef103 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -201,7 +201,7 @@ const getFingerprintStatus = ( , + hash_type: , }} /> diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 630267c72..3d4d40a1c 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -49,7 +49,7 @@ const criterionOptions = [ createStringCriterionOption("details"), createStringCriterionOption("photographer"), PathCriterionOption, - createStringCriterionOption("checksum", "media_info.checksum"), + createStringCriterionOption("checksum", "media_info.md5"), RatingCriterionOption, OrganizedCriterionOption, AverageResolutionCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 2d3db8265..9468e5eaf 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -47,7 +47,7 @@ const criterionOptions = [ createStringCriterionOption("code", "scene_code"), createStringCriterionOption("details"), createStringCriterionOption("photographer"), - createMandatoryStringCriterionOption("checksum", "media_info.checksum"), + createMandatoryStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, PathCriterionOption, GalleriesCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 251e2592d..f4f93deeb 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -97,8 +97,8 @@ const criterionOptions = [ PathCriterionOption, createStringCriterionOption("details"), createStringCriterionOption("director"), - createMandatoryStringCriterionOption("oshash", "media_info.hash"), - createStringCriterionOption("checksum", "media_info.checksum"), + createMandatoryStringCriterionOption("oshash", "media_info.oshash"), + createStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, DuplicatedCriterionOption, OrganizedCriterionOption, From 01d351c85d57b4a580cefd4482c1499afa829c05 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:56:24 -0800 Subject: [PATCH 010/152] FR: Custom Fields Frontend (#6601) * Add "custom-field-" prefix to custom field detail item ids --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/graphql/data/gallery.graphql | 2 + ui/v2.5/graphql/data/group.graphql | 2 + ui/v2.5/graphql/data/image.graphql | 2 + ui/v2.5/graphql/data/scene.graphql | 2 + ui/v2.5/graphql/data/studio.graphql | 1 + ui/v2.5/graphql/queries/scene.graphql | 8 ++ .../GalleryDetails/GalleryDetailPanel.tsx | 2 + .../GalleryDetails/GalleryEditPanel.tsx | 39 ++++++- ui/v2.5/src/components/Galleries/styles.scss | 14 +++ .../Groups/GroupDetails/GroupDetailsPanel.tsx | 2 + .../Groups/GroupDetails/GroupEditPanel.tsx | 37 +++++- .../Images/ImageDetails/ImageDetailPanel.tsx | 2 + .../Images/ImageDetails/ImageEditPanel.tsx | 30 ++++- ui/v2.5/src/components/Images/styles.scss | 14 +++ .../PerformerDetails/PerformerEditPanel.tsx | 19 +-- ui/v2.5/src/components/Performers/styles.scss | 5 - .../Scenes/SceneDetails/SceneDetailPanel.tsx | 2 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 39 ++++++- .../components/Scenes/SceneMergeDialog.tsx | 109 ++++++++++++++---- ui/v2.5/src/components/Scenes/styles.scss | 14 +++ .../src/components/Shared/CustomFields.tsx | 25 ++-- ui/v2.5/src/components/Shared/styles.scss | 35 ++++++ .../StudioDetails/StudioDetailsPanel.tsx | 2 + .../Studios/StudioDetails/StudioEditPanel.tsx | 38 +++++- .../Tags/TagDetails/TagDetailsPanel.tsx | 2 + .../Tags/TagDetails/TagEditPanel.tsx | 36 +++++- ui/v2.5/src/core/StashService.ts | 8 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/groups.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/studios.ts | 2 + ui/v2.5/src/models/list-filter/tags.ts | 2 + 33 files changed, 434 insertions(+), 69 deletions(-) diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 89f3ed44c..349a52ad7 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -39,6 +39,8 @@ fragment GalleryData on Gallery { scenes { ...SlimSceneData } + + custom_fields } fragment SelectGalleryData on Gallery { diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 440c420da..a9968bbae 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -39,6 +39,8 @@ fragment GroupData on Group { id title } + + custom_fields } # Lightweight fragment for list views - excludes expensive recursive counts diff --git a/ui/v2.5/graphql/data/image.graphql b/ui/v2.5/graphql/data/image.graphql index 52163b007..63ce5b458 100644 --- a/ui/v2.5/graphql/data/image.graphql +++ b/ui/v2.5/graphql/data/image.graphql @@ -37,4 +37,6 @@ fragment ImageData on Image { visual_files { ...VisualFileData } + + custom_fields } diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index e4a6e5cc6..b7378c1da 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -79,6 +79,8 @@ fragment SceneData on Scene { mime_type label } + + custom_fields } fragment SelectSceneData on Scene { diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 8347b4739..0e23a885e 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -41,6 +41,7 @@ fragment StudioData on Studio { ...SlimTagData } o_counter + custom_fields } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql index d6a3afd47..0e1a9fa11 100644 --- a/ui/v2.5/graphql/queries/scene.graphql +++ b/ui/v2.5/graphql/queries/scene.graphql @@ -40,6 +40,14 @@ query FindScene($id: ID!, $checksum: String) { } } +query FindFullScenes($ids: [Int!]) { + findScenes(scene_ids: $ids) { + scenes { + ...SceneData + } + } +} + query FindSceneMarkerTags($id: ID!) { sceneMarkerTags(scene_id: $id) { tag { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 597a57b15..ead882ec0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { PhotographerLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGalleryDetailProps { gallery: GQL.GalleryDataFragment; @@ -108,6 +109,7 @@ export const GalleryDetailPanel: React.FC = ({ {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 04b802784..14b5d6aad 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,6 +31,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { gallery: Partial; @@ -76,6 +81,7 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).defined(), scene_ids: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -89,15 +95,26 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: (gallery?.tags ?? []).map((t) => t.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id), details: gallery?.details ?? "", + custom_fields: cloneDeep(gallery?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -189,7 +206,10 @@ export const GalleryEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -455,7 +475,9 @@ export const GalleryEditPanel: React.FC = ({ id="gallery-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -468,7 +490,9 @@ export const GalleryEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -523,6 +547,13 @@ export const GalleryEditPanel: React.FC = ({ {cover} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index c53175313..ac9330e9a 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -208,6 +208,20 @@ $galleryTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .gallery-cover { diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index b8e39ffe6..8ae4b16a9 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -6,6 +6,7 @@ import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { GroupLink, TagLink } from "src/components/Shared/TagLink"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGroupDescription { group: GQL.SlimGroupDataFragment; @@ -101,6 +102,7 @@ export const GroupDetailsPanel: React.FC = ({ fullWidth={fullWidth} /> )} + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index f0a6f17c1..6401738fa 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,6 +28,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IGroupEditPanel { group: Partial; @@ -84,6 +89,7 @@ export const GroupEditPanel: React.FC = ({ synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const GroupEditPanel: React.FC = ({ director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", + custom_fields: cloneDeep(group?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -220,7 +237,10 @@ export const GroupEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -458,6 +478,13 @@ export const GroupEditPanel: React.FC = ({ {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onFrontImageChange} onImageChangeURL={onFrontImageLoad} onClearImage={() => onFrontImageLoad(null)} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index a2044fcff..cf33b648b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -7,6 +7,7 @@ import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; import { PatchComponent } from "../../../patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IImageDetailProps { image: GQL.ImageDataFragment; } @@ -132,6 +133,7 @@ export const ImageDetailPanel: React.FC = PatchComponent( {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 58b809d41..94dddac4b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,6 +35,11 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { image: GQL.ImageDataFragment; @@ -86,6 +91,7 @@ export const ImageEditPanel: React.FC = ({ studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const ImageEditPanel: React.FC = ({ studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), + custom_fields: cloneDeep(image.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -444,7 +461,9 @@ export const ImageEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -492,6 +511,13 @@ export const ImageEditPanel: React.FC = ({ {renderDetailsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 0050a9434..43ac56590 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -179,6 +179,20 @@ $imageTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .image-file-card.card { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 98871bf9a..93b69e7b5 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -48,7 +48,10 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; -import { CustomFieldsInput } from "src/components/Shared/CustomFields"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; const isScraper = ( @@ -67,16 +70,6 @@ interface IPerformerDetails { setEncodingImage: (loading: boolean) => void; } -function customFieldInput(isNew: boolean, input: {}) { - if (isNew) { - return input; - } else { - return { - full: input, - }; - } -} - export const PerformerEditPanel: React.FC = ({ performer, isVisible, @@ -173,7 +166,7 @@ export const PerformerEditPanel: React.FC = ({ function submit(values: InputValues) { const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } @@ -368,7 +361,7 @@ export const PerformerEditPanel: React.FC = ({ const { values } = formik; const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input, true); } diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 54a010e50..49dc27550 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -82,11 +82,6 @@ font-weight: 700; padding-left: 0; } - - .custom-fields .detail-item-title, - .custom-fields .detail-item-value { - font-family: "Courier New", Courier, monospace; - } /* stylelint-enable selector-class-pattern */ } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index ad7663e9d..b109016b1 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { DirectorLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ISceneDetailProps { scene: GQL.SceneDataFragment; @@ -103,6 +104,7 @@ export const SceneDetailPanel: React.FC = (props) => { {renderDetails()} {renderTags()} {renderPerformers()} + diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 54bf5b573..41293ff78 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -50,6 +50,11 @@ import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -140,6 +145,7 @@ export const SceneEditPanel: React.FC = ({ stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = useMemo( @@ -159,17 +165,28 @@ export const SceneEditPanel: React.FC = ({ stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, + custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] ); type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -288,7 +305,10 @@ export const SceneEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -759,7 +779,9 @@ export const SceneEditPanel: React.FC = ({ id="scene-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -772,7 +794,9 @@ export const SceneEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -863,6 +887,13 @@ export const SceneEditPanel: React.FC = ({ onReset={scene.id ? onResetCover : undefined} /> + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 9455af186..89d445002 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -7,12 +7,16 @@ import { StringListSelect, GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; -import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; +import { + mutateSceneMerge, + queryFindFullScenesByID, +} from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialogRow, + ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, @@ -24,6 +28,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { + CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, @@ -52,8 +57,8 @@ type MergeOptions = { }; interface ISceneMergeDetailsProps { - sources: GQL.SlimSceneDataFragment[]; - dest: GQL.SlimSceneDataFragment; + sources: GQL.SceneDataFragment[]; + dest: GQL.SceneDataFragment; onClose: (options?: MergeOptions) => void; } @@ -173,6 +178,10 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.paths.screenshot) ); + const [customFields, setCustomFields] = useState( + new Map() + ); + // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { @@ -309,28 +318,64 @@ const SceneMergeDetails: React.FC = ({ ) ); + const customFieldNames = new Set( + Object.keys(dest.custom_fields ?? {}) + ); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields ?? {})) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + loadImages(); }, [sources, dest]); + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + // ensure this is updated if fields are changed const hasValues = useMemo(() => { - return hasScrapedValues([ - title, - code, - url, - date, - rating, - oCounter, - galleries, - studio, - performers, - groups, - tags, - details, - organized, - stashIDs, - image, - ]); + return ( + hasCustomFieldValues || + hasScrapedValues([ + title, + code, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + groups, + tags, + details, + organized, + stashIDs, + image, + ]) + ); }, [ title, code, @@ -347,6 +392,7 @@ const SceneMergeDetails: React.FC = ({ organized, stashIDs, image, + hasCustomFieldValues, ]); function renderScrapeRows() { @@ -566,6 +612,12 @@ const SceneMergeDetails: React.FC = ({ result={image} onChange={(value) => setImage(value)} /> + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } @@ -606,6 +658,13 @@ const SceneMergeDetails: React.FC = ({ organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, @@ -655,10 +714,10 @@ export const SceneMergeModal: React.FC = ({ const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); - const [loadedSources, setLoadedSources] = useState< - GQL.SlimSceneDataFragment[] - >([]); - const [loadedDest, setLoadedDest] = useState(); + const [loadedSources, setLoadedSources] = useState( + [] + ); + const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); @@ -684,7 +743,7 @@ export const SceneMergeModal: React.FC = ({ async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); - const query = await queryFindScenesByID(sceneIDs); + const query = await queryFindFullScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 78644b4c9..3f142f4bd 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -562,6 +562,20 @@ input[type="range"].blue-slider { .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .scene-markers-panel { diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index c8d389a17..e6e892f7c 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -18,6 +18,7 @@ export type CustomFieldMap = { interface ICustomFields { values: CustomFieldMap; + fullWidth?: boolean; } function convertValue(value: unknown): string { @@ -41,7 +42,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ const valueStr = convertValue(value); // replace spaces with hyphen characters for css id - const id = field.toLowerCase().replace(/ /g, "-"); + const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`; return ( = ({ export const CustomFields: React.FC = PatchComponent( "CustomFields", - ({ values }) => { + ({ values, fullWidth }) => { const intl = useIntl(); if (Object.keys(values).length === 0) { return null; @@ -65,7 +66,7 @@ export const CustomFields: React.FC = PatchComponent( return ( // according to linter rule CSS classes shouldn't use underscores -
+
@@ -125,7 +126,7 @@ const CustomFieldInput: React.FC<{ - + {isNew ? ( <> {currentField} )} - + void; } +export function formatCustomFieldInput(isNew: boolean, input: {}) { + if (isNew) { + return input; + } else { + return { + full: input, + }; + } +} + export const CustomFieldsInput: React.FC = PatchComponent( "CustomFieldsInput", ({ values, error, onChange, setError }) => { @@ -282,10 +293,10 @@ export const CustomFieldsInput: React.FC = PatchComponent( - + - + diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 32b222832..97a5c4387 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -795,6 +795,11 @@ button.btn.favorite-button { .detail-item { max-width: 100%; } + + .detail-item-title, + .detail-item-value { + font-family: "Courier New", Courier, monospace; + } } .custom-fields .detail-item .detail-item-title { @@ -816,6 +821,36 @@ button.btn.favorite-button { font-weight: 700; } +.custom-fields-input { + .custom-fields-field { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 25%; + max-width: 25%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 16.667%; + max-width: 16.667%; + } + } + + .custom-fields-value { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 75%; + max-width: 75%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 58.33%; + max-width: 58.33%; + } + } +} + .custom-fields-row { align-items: center; font-family: "Courier New", Courier, monospace; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 5ad92100f..ae8314fe8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -4,6 +4,7 @@ import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "src/patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { @@ -87,6 +88,7 @@ export const StudioDetailsPanel: React.FC = PatchComponent( value={renderStashIDs()} fullWidth={fullWidth} /> +
); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index f887e5403..490f09a55 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -21,6 +21,11 @@ import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IStudioEditPanel { studio: Partial; @@ -63,6 +68,7 @@ export const StudioEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -75,15 +81,26 @@ export const StudioEditPanel: React.FC = ({ tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), + custom_fields: cloneDeep(studio.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tagsControl } = useTagsEdit(studio.tags, (ids) => @@ -144,7 +161,10 @@ export const StudioEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -242,6 +262,14 @@ export const StudioEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -254,7 +282,11 @@ export const StudioEditPanel: React.FC = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onImageChange} onImageChangeURL={onImageLoad} onClearImage={() => onImageLoad(null)} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx index 92c92d072..bf2e80c91 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx @@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ITagDetails { tag: GQL.TagDataFragment; @@ -90,6 +91,7 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { value={renderStashIDs()} fullWidth={fullWidth} /> +
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 22c99b80e..21cd32c53 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -20,6 +20,11 @@ import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface ITagEditPanel { tag: Partial; @@ -63,6 +68,7 @@ export const TagEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -74,15 +80,26 @@ export const TagEditPanel: React.FC = ({ child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, stash_ids: getStashIDs(tag?.stash_ids), + custom_fields: cloneDeep(tag?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); function onSetParentTags(items: Tag[]) { @@ -134,7 +151,10 @@ export const TagEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -266,6 +286,14 @@ export const TagEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -279,7 +307,9 @@ export const TagEditPanel: React.FC = ({ onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onImageChange={onImageChange} onImageChangeURL={onImageLoad} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 27186d6e1..535beed65 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -166,6 +166,14 @@ export const queryFindScenesByID = (sceneIDs: number[]) => }, }); +export const queryFindFullScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindFullScenesDocument, + variables: { + ids: sceneIDs, + }, + }); + export const queryFindScenesForSelect = (filter: ListFilterModel) => client.query({ query: GQL.FindScenesForSelectDocument, diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 3d4d40a1c..adac37e3c 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -21,6 +21,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -71,6 +72,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index ee0c90d73..9c5b3f2d4 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -17,6 +17,7 @@ import { ContainingGroupsCriterionOption, SubGroupsCriterionOption, } from "./criteria/groups"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; @@ -66,6 +67,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("scene_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GroupListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 9468e5eaf..eabcbfd26 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -23,6 +23,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { PhashCriterionOption } from "./criteria/phash"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -73,6 +74,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("file_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f4f93deeb..c0e4a75a1 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -35,6 +35,7 @@ import { StashIDCriterionOption } from "./criteria/stash-ids"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "date"; const sortByOptions = [ @@ -141,6 +142,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index a38540a47..e62d41c7a 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -13,6 +13,7 @@ import { ParentStudiosCriterionOption } from "./criteria/studios"; import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = [ @@ -67,6 +68,7 @@ const criterionOptions = [ ), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 39ce9ca39..4c8bed69f 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -15,6 +15,7 @@ import { } from "./criteria/tags"; import { FavoriteTagCriterionOption } from "./criteria/favorite"; import { StashIDCriterionOption } from "./criteria/stash-ids"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = ["name", "random", "scenes_duration"] @@ -77,6 +78,7 @@ const criterionOptions = [ new MandatoryNumberCriterionOption("sub_tag_count", "child_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const TagListFilterOptions = new ListFilterOptions( From c9f0dba62f2dd4ecdb03ac80e5cca9cc65207696 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:54:12 +0200 Subject: [PATCH 011/152] Fix capitalization in custom localisation heading [skip-ci] (#6606) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b7d3e2894..e19f9ca8f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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": { From 5734ee43ff788d12c2caa604de2bfa18358fb269 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:54:40 +1100 Subject: [PATCH 012/152] 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 --- graphql/schema/types/filters.graphql | 6 + pkg/models/performer.go | 4 + pkg/models/tag.go | 2 + pkg/sqlite/criterion_handlers.go | 13 +- pkg/sqlite/performer_filter.go | 27 + pkg/sqlite/tag_filter.go | 14 + .../List/Filters/LabeledIdFilter.tsx | 19 + .../src/components/Scenes/SceneMarkerList.tsx | 538 ++++++++++++++---- .../Tags/TagDetails/TagMarkersPanel.tsx | 4 +- 9 files changed, 504 insertions(+), 123 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 907e597f4..d9814ef34 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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 diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e4fb8dd98..8de5d94f4 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -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 diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 3a133dcad..b166e5a69 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -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 diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 1496df71d..943704cfe 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -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) { diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 5296d5a25..e99f3068f 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -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 { ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, ×tampCriterionHandler{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, @@ -387,6 +398,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, diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index b3a7c1756..4e2313080 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -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") + }, + }, } } diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index a9163578f..f19472d64 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -24,6 +24,7 @@ import { IntCriterionInput, PerformerFilterType, SceneFilterType, + SceneMarkerFilterType, StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -527,6 +528,8 @@ interface IFilterType { group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; + marker_count?: InputMaybe; + markers_filter?: InputMaybe; } export function setObjectFilter( @@ -549,6 +552,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.scenes_filter = relatedFilterOutput as SceneFilterType; break; @@ -559,6 +563,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.performers_filter = relatedFilterOutput as PerformerFilterType; break; @@ -569,6 +574,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; @@ -579,6 +585,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.groups_filter = relatedFilterOutput as GroupFilterType; break; @@ -589,9 +596,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"); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b5975ca5a..781a3f0b2 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -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; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "SceneList", + ({ markers, filter, selectedIds, onSelectChange }) => { + if (markers.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + + 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; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + + + + +
+ +
+ + ); +}; + interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -36,132 +198,274 @@ interface ISceneMarkerList { extraOperations?: IItemListOperation[]; } -export const SceneMarkerList: React.FC = 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( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [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, - 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 ( - - ); - } + 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( + // closeModal()} + // /> + // ), + // isDisplayed: () => hasSelection, + // }, + ]; - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderEditDialog( - selectedMarkers: GQL.SceneMarkerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedSceneMarkers: GQL.SceneMarkerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+ ); } ); -export default SceneMarkerList; +export default FilteredSceneMarkerList; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index f32f26497..63b906c80 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -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 = ({ const filterHook = useFilterHook(tag, showSubTagContent); return ( - Date: Thu, 26 Feb 2026 07:55:26 +1100 Subject: [PATCH 013/152] 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 " (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 --- .../FrontPage/FilteredRecommendationRow.tsx | 79 +++++++++++++++++++ .../FrontPage/RecommendationRow.tsx | 2 +- .../Galleries/GalleryRecommendationRow.tsx | 55 +++++-------- .../Groups/GroupRecommendationRow.tsx | 52 ++++-------- .../Images/ImageRecommendationRow.tsx | 52 ++++-------- ui/v2.5/src/components/List/FilterTags.tsx | 29 ++++++- ui/v2.5/src/components/List/styles.scss | 4 + .../Performers/PerformerRecommendationRow.tsx | 55 +++++-------- .../Scenes/SceneMarkerRecommendationRow.tsx | 67 ++++++---------- .../Scenes/SceneRecommendationRow.tsx | 64 ++++++--------- .../src/components/Shared/HoverPopover.tsx | 19 +++++ ui/v2.5/src/components/Shared/styles.scss | 13 +++ .../Studios/StudioRecommendationRow.tsx | 55 +++++-------- .../components/Tags/TagRecommendationRow.tsx | 49 ++++-------- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/criterion.ts | 51 ++++++++++++ ui/v2.5/src/models/list-filter/filter.ts | 4 +- 17 files changed, 355 insertions(+), 297 deletions(-) create mode 100644 ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx diff --git a/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx new file mode 100644 index 000000000..8cf27a625 --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx @@ -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 = PatchComponent( + "FilteredRecommendationRow", + (props) => { + const cardCount = props.count; + + const unsupportedCriteria = props.filter.criteria.filter( + (criterion) => criterion instanceof UnsupportedCriterion + ); + + const header = unsupportedCriteria.length ? ( +
+ {props.heading} + + c.criterionOption.type) + .join(", "), + }} + /> + + } + /> +
+ ) : ( + props.heading + ); + + if (!props.loading && !cardCount) { + return null; + } + + return ( + + + + } + > + + {props.children} + + + ); + } +); diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx index 115d8642a..97e43f294 100644 --- a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -3,7 +3,7 @@ import { PatchComponent } from "src/patch"; interface IProps { className?: string; - header: string; + header: React.ReactNode; link: JSX.Element; } diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index b56b48c36..3df07b643 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -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 = 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 ( - - - - } + heading={props.header} + url={`/galleries?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGalleries.galleries.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGalleries.galleries.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 228cb3467..b9e523b34 100644 --- a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -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 = 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 ( - - - - } + heading={props.header} + url={`/groups?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGroups.groups.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGroups.groups.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index 6499be894..0541e5934 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -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 = 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 ( - - - - } + heading={props.header} + url={`/images?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findImages.images.map((i) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 5597cae79..28c9f77fa 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -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; onRemove: React.MouseEventHandler; -}> = ({ className, label, onClick, onRemove }) => { + unsupported?: boolean; +}> = ({ className, label, onClick, onRemove, unsupported }) => { + function handleClick(e: React.MouseEvent) { + if (unsupported) { + return; + } + onClick(e); + } + return ( - + + {unsupported && ( + + )} {label} + + + ); +}; + +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 = 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(false); - const filterMode = GQL.FilterMode.Images; + const searchFocus = useFocus(); - const { modal, showModal, closeModal } = useModal(); + const withSidebar = props.view !== View.GalleryImages; - const otherOperations: IItemListOperation[] = [ - ...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( + closeModal()} + /> + ); + } + + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + 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( = 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 = ( + + ); - 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 = ( + <> + - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } +
+ setFilter(filter.changePage(page))} + /> + +
- function renderContent( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: ( - id: string, - selected: boolean, - shiftKey: boolean - ) => void, - onChangePage: (page: number) => void, - pageCount: number - ) { - function maybeRenderImageExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderImages() { - if (!result.data?.findImages) return; - - return ( - + setFilter(filter.changePage(page))} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} @@ -478,54 +767,60 @@ export const ImageList: React.FC = PatchComponent( setSlideshowRunning={setSlideshowRunning} chapters={chapters} /> - ); - } + - return ( - <> - {maybeRenderImageExportDialog()} - {renderImages()} - - ); - } + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); - function renderEditDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; + if (!withSidebar) { + return content; } return ( - {modal} - - + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + + ); } ); diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 91edfdf79..932bbc2c1 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -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 ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index f19472d64..e006d6b50 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -20,6 +20,7 @@ import { FilterMode, GalleryFilterType, GroupFilterType, + ImageFilterType, InputMaybe, IntCriterionInput, PerformerFilterType, @@ -524,6 +525,8 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + images_filter?: InputMaybe; + image_count?: InputMaybe; groups_filter?: InputMaybe; group_count?: InputMaybe; studios_filter?: InputMaybe; @@ -578,6 +581,17 @@ export function setObjectFilter( } 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) { diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 67d09e721..962e3fc4c 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -46,16 +46,21 @@ import { useConfigurationContext } from "src/hooks/Config"; import { useZoomKeybinds } from "./ZoomSlider"; import { DisplayMode } from "src/models/list-filter/types"; -interface IFilteredItemList { +interface IFilteredItemList< + T extends QueryResult, + E extends IHasID = IHasID, + M = unknown +> { filterStateProps: IFilterStateHook; - queryResultProps: IQueryResultHook; + queryResultProps: IQueryResultHook; } // Provides the common state and behaviour for filtered item list components export function useFilteredItemList< T extends QueryResult, - E extends IHasID = IHasID ->(props: IFilteredItemList) { + E extends IHasID = IHasID, + M = unknown +>(props: IFilteredItemList) { const { configuration: config } = useConfigurationContext(); // States @@ -70,7 +75,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,6 +112,7 @@ export function useFilteredItemList< return { filterState, queryResult, + metadataInfo, listSelect, modalState, showEditFilter, diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index d870c631f..89c32222f 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -509,23 +509,27 @@ export function useCachedQueryResult( 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 & { + props: IQueryResultHook & { 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, diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 7b088e5be..bd1484a17 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -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 = PatchComponent("PerformerImagesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( - "SceneList", + "SceneMarkerList", ({ markers, filter, selectedIds, onSelectChange }) => { if (markers.length === 0) { return null; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index a81c91462..f81599ceb 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -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 = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - ; ExternalLinksButton: React.FC; FilteredGalleryList: React.FC; + FilteredGroupList: React.FC; + FilteredImageList: React.FC; + FilteredPerformerList: React.FC; FilteredSceneList: React.FC; + FilteredSceneMarkerList: React.FC; + FilteredStudioList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; From b77abd64e2e98c9410de3a9666da62430123f5a0 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:36:54 -0800 Subject: [PATCH 015/152] FR: Add Missing is-missing Filter Options Across all Object Types (#6565) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/gallery_filter.go | 9 +++ pkg/sqlite/group_filter.go | 18 ++++++ pkg/sqlite/image_filter.go | 9 +++ pkg/sqlite/performer_filter.go | 12 ++++ pkg/sqlite/scene_filter.go | 6 ++ pkg/sqlite/sql.go | 10 ++++ pkg/sqlite/studio_filter.go | 12 ++++ pkg/sqlite/tag_filter.go | 12 ++++ .../models/list-filter/criteria/is-missing.ts | 60 +++++++++++++++++-- 9 files changed, 142 insertions(+), 6 deletions(-) diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f920e442a..069bb1015 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -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 + ") = '')") } } diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index 4f3f7b41a..14f3841f4 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -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 + ") = '')") } } diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index aafd2aa40..4d1d2c4b3 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -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 + ") = '')") } } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index e99f3068f..fdcc283ab 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -316,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 + ") = '')") } } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index a9eb6b0ae..712c3d83d 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -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 + ") = '')") } } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 0b55af8db..70d86ab5e 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -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" diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index cfe3c59b6..6d5a8fe7c 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -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 + ") = '')") } } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 4e2313080..5fd41e80a 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -198,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 + ") = '')") } } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 512616f3c..821870e47 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -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", + ] ); From e52ac14d56945c1aba0626b81cbf641dbf8b5791 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:42:53 +1100 Subject: [PATCH 016/152] 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 --- internal/api/resolver_mutation_file.go | 11 +++-- internal/manager/config/stash_config.go | 8 +++ internal/manager/manager_tasks.go | 3 +- pkg/file/folder.go | 61 ++++++++++++++++------- pkg/file/move.go | 14 +++++- pkg/file/scan.go | 66 ++++++++++++++++++++----- 6 files changed, 125 insertions(+), 38 deletions(-) diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index f6279ad16..b9e36aa76 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -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) } diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 4a2cc7d60..3854c707b 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -38,3 +38,11 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { } return nil } + +func (s StashConfigs) Paths() []string { + paths := make([]string, len(s)) + for i, c := range s { + paths[i] = c.Path + } + return paths +} diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index e97227fcf..e84fda9b9 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -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{ diff --git a/pkg/file/folder.go b/pkg/file/folder.go index fe260c155..e3e14186b 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -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,30 @@ 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 { + panic(fmt.Sprintf("parent path is the same as the current path: %s", path)) + } + + 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 +56,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 +66,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 +86,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 +106,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 +114,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 +129,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 +152,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 +160,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) } } diff --git a/pkg/file/move.go b/pkg/file/move.go index ba2a496bb..06605912b 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -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) } diff --git a/pkg/file/scan.go b/pkg/file/scan.go index d9a58ad44..cf1b43603 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -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) } } From 660feabced3494e36ca5add5501e5f43f30dcf54 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:43:16 +1100 Subject: [PATCH 017/152] Update minimatch and ajv dependencies (#6609) * Update minimatch * Update ajv --- ui/v2.5/pnpm-lock.yaml | 88 +++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 02033c41f..46dcec4d8 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -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 From ead0c7fe077723dbf0ae93b650e5f7a95e217371 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:44:23 +1100 Subject: [PATCH 018/152] 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 --- ui/v2.5/src/components/Images/ImageList.tsx | 2 +- ui/v2.5/src/components/List/ItemList.tsx | 372 +-------- ui/v2.5/src/components/Tags/TagList.tsx | 874 ++++++++++++-------- ui/v2.5/src/components/Tags/Tags.tsx | 4 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 2 + ui/v2.5/src/pluginApi.d.ts | 1 + 6 files changed, 528 insertions(+), 727 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 8c11abdee..cc8aa48f7 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -618,7 +618,7 @@ export const FilteredImageList = PatchComponent( showModal( { - view?: View; - otherOperations?: IItemListOperation[]; - renderContent: ( - result: T, - filter: ListFilterModel, - selectedIds: Set, - 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 - ) => () => void; - renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; -} - -export const ItemList = ( - props: IItemListProps -) => { - const { - view, - otherOperations, - renderContent, - renderEditDialog, - renderDeleteDialog, - renderMetadataByline, - addKeybinds, - renderToolbar: providedToolbar, - } = props; - - const { filter, setFilter: updateFilter } = useFilter(); - const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } = - useQueryResultContext(); - const listSelect = useListContext(); - 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( - 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) { - 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 ( -
- {providedToolbar ? ( - providedToolbar(filterListToolbarProps) - ) : ( - - )} - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={onRemoveCriterion} - onRemoveAll={() => onClearAllCriteria()} - /> - {modal} - - - {renderContent( - result, - // #4780 - use effectiveFilter to ensure filterHook is applied - effectiveFilter, - selectedIds, - onSelectChange, - onChangePage, - pages - )} - -
- ); -}; - -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> -) => { - 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( - () => - new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort }) - ); - - const { defaultFilter } = useDefaultFilter(emptyFilter, view); - - return ( - - - - {({ items }) => ( - - {children} - - )} - - - - ); -}; - export const showWhenSelected = ( result: T, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 61b81b727..38cc13141 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -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; + 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 ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + const tagElements = tags.map((tag) => { + return ( +
+ {tag.name} + +
+ + + + + + + :{" "} + + + +
+
+ ); + }); + + return
{tagElements}
; + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + 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; +}> = ({ + filter, + setFilter, + // filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + {/* */} + } + filter={filter} + setFilter={setFilter} + option={FavoriteTagCriterionOption} + sectionID="favourite" + /> + + +
+ +
+ + ); +}; + +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[]; } -export const TagList: React.FC = PatchComponent( - "TagList", - ({ filterHook, alterQuery, extraOperations = [] }) => { - const Toast = useToast(); - const [deletingTag, setDeletingTag] = - useState | null>(null); - - const filterMode = GQL.FilterMode.Tags; - const view = View.Tags; - - function getDeleteTagInput() { - const tagInput: Partial = {}; - 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(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( + 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( + + ); } - async function merge( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - 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( + { + 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( + { + onCloseEditDelete(); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show + /> + ); } async function onAutoTag(tag: GQL.TagListDataFragment) { @@ -157,269 +443,151 @@ export const TagList: React.FC = 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, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function renderMergeDialog() { - if (mergeTags) { - return ( - { - 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 ( - setIsExportDialogOpen(false)} - /> - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderTags() { - if (!result.data?.findTags) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - const deleteAlert = ( - {}} - show={!!deletingTag} - icon={faTrashAlt} - accept={{ - onClick: onDelete, - variant: "danger", - text: intl.formatMessage({ id: "actions.delete" }), - }} - cancel={{ onClick: () => setDeletingTag(null) }} - > - - - - - ); - - const tagElements = result.data.findTags.tags.map((tag) => { - return ( -
- {tag.name} - -
- - - - - - - :{" "} - - - -
-
- ); - }); - - return ( -
- {tagElements} - {deleteAlert} -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; - } - } - return ( - <> - {renderMergeDialog()} - {maybeRenderExportDialog()} - {renderTags()} - - ); - } - - function renderEditDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - { - selectedTags.forEach((t) => - tagRelationHook( - t, - { parents: t.parents ?? [], children: t.children ?? [] }, - { parents: [], children: [] } - ) - ); - }} - /> - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + onDelete(tag)} + onAutoTag={(tag) => onAutoTag(tag)} + /> + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+ ); } ); diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx index 806a0f7a6..a4336fea9 100644 --- a/ui/v2.5/src/components/Tags/Tags.tsx +++ b/ui/v2.5/src/components/Tags/Tags.tsx @@ -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 ; + return ; }; const TagRoutes: React.FC = () => { diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 4ff8b5143..68d5676d3 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -236,6 +236,7 @@ Returns `void`. - `FilteredSceneList` - `FilteredSceneMarkerList` - `FilteredStudioList` +- `FilteredTagList` - `FolderSelect` - `FrontPage` - `GalleryCard` @@ -353,6 +354,7 @@ Returns `void`. - `TagCardGrid` - `TagIDSelect` - `TagLink` +- `TagList` - `TagRecommendationRow` - `TagSelect` - `TagSelect.sort` diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 77627be10..e04d472b6 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -673,6 +673,7 @@ declare namespace PluginApi { FilteredSceneList: React.FC; FilteredSceneMarkerList: React.FC; FilteredStudioList: React.FC; + FilteredTagList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; From d8448ba37ecf7749b4d75356b33be471ac6c8fdd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:58:11 +1100 Subject: [PATCH 019/152] 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. --- graphql/schema/types/file.graphql | 3 + graphql/schema/types/filters.graphql | 1 + internal/api/loaders/dataloaders.go | 29 +- .../folderparentfolderidsloader_gen.go | 225 ++++++++++++++ internal/api/resolver_model_folder.go | 16 + pkg/file/zip.go | 4 +- pkg/models/folder.go | 6 +- pkg/models/mocks/FolderReaderWriter.go | 23 ++ pkg/models/repository_folder.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/folder.go | 87 ++++++ pkg/sqlite/folder_filter.go | 1 + pkg/sqlite/folder_filter_test.go | 11 + pkg/sqlite/folder_test.go | 74 ++++- .../migrations/84_folder_basename.up.sql | 50 +++ pkg/sqlite/migrations/84_postmigrate.go | 285 ++++++++++++++++++ pkg/sqlite/setup_test.go | 9 +- 17 files changed, 814 insertions(+), 13 deletions(-) create mode 100644 internal/api/loaders/folderparentfolderidsloader_gen.go create mode 100644 pkg/sqlite/migrations/84_folder_basename.up.sql create mode 100644 pkg/sqlite/migrations/84_postmigrate.go diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 835479fad..37fb5539f 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -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! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d9814ef34..6eda473b4 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -822,6 +822,7 @@ input FolderFilterType { NOT: FolderFilterType path: StringCriterionInput + basename: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput zip_file: MultiCriterionInput diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index dac8ba6b8..2ba650962 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -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 { diff --git a/internal/api/loaders/folderparentfolderidsloader_gen.go b/internal/api/loaders/folderparentfolderidsloader_gen.go new file mode 100644 index 000000000..c9eca3a3d --- /dev/null +++ b/internal/api/loaders/folderparentfolderidsloader_gen.go @@ -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) +} diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go index ee6bbfd05..c203a3f82 100644 --- a/internal/api/resolver_model_folder.go +++ b/internal/api/resolver_model_folder.go @@ -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) } diff --git a/pkg/file/zip.go b/pkg/file/zip.go index 5afcd5329..6d00c7e35 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -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 diff --git a/pkg/models/folder.go b/pkg/models/folder.go index ada9e17b7..e9e9a3971 100644 --- a/pkg/models/folder.go +++ b/pkg/models/folder.go @@ -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 diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 7bca013fe..5d4d95027 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -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) diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 3d0fdb822..539d51cb9 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -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 { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 003c6eebc..f8c2cdef7 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -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 diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index f250f7861..73a065cff 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -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() diff --git a/pkg/sqlite/folder_filter.go b/pkg/sqlite/folder_filter.go index 6b2bd96e9..e0145bcca 100644 --- a/pkg/sqlite/folder_filter.go +++ b/pkg/sqlite/folder_filter.go @@ -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")), ×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil}, qb.parentFolderCriterionHandler(folderFilter.ParentFolder), diff --git a/pkg/sqlite/folder_filter_test.go b/pkg/sqlite/folder_filter_test.go index c1c7d7a37..c08208f30 100644 --- a/pkg/sqlite/folder_filter_test.go +++ b/pkg/sqlite/folder_filter_test.go @@ -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{ diff --git a/pkg/sqlite/folder_test.go b/pkg/sqlite/folder_test.go index 15b2b96b8..072a1167f 100644 --- a/pkg/sqlite/folder_test.go +++ b/pkg/sqlite/folder_test.go @@ -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) + }) + } +} diff --git a/pkg/sqlite/migrations/84_folder_basename.up.sql b/pkg/sqlite/migrations/84_folder_basename.up.sql new file mode 100644 index 000000000..5cfd5c2d9 --- /dev/null +++ b/pkg/sqlite/migrations/84_folder_basename.up.sql @@ -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; \ No newline at end of file diff --git a/pkg/sqlite/migrations/84_postmigrate.go b/pkg/sqlite/migrations/84_postmigrate.go new file mode 100644 index 000000000..71b0feeb0 --- /dev/null +++ b/pkg/sqlite/migrations/84_postmigrate.go @@ -0,0 +1,285 @@ +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 76") + + 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.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) 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) +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 2848a0a14..d8baae3b8 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -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) } From 3b8f6bd94c4f9efddd5f517ee9a0f1b22b285abd Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:11:13 -0800 Subject: [PATCH 020/152] update logs and fix UNIQUE constraint failure (#6617) --- pkg/sqlite/migrations/84_postmigrate.go | 102 +++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/migrations/84_postmigrate.go b/pkg/sqlite/migrations/84_postmigrate.go index 71b0feeb0..3be0dd22e 100644 --- a/pkg/sqlite/migrations/84_postmigrate.go +++ b/pkg/sqlite/migrations/84_postmigrate.go @@ -17,7 +17,7 @@ import ( ) func post84(ctx context.Context, db *sqlx.DB) error { - logger.Info("Running post-migration for schema version 76") + logger.Info("Running post-migration for schema version 84") m := schema84Migrator{ migrator: migrator{ @@ -32,6 +32,10 @@ func post84(ctx context.Context, db *sqlx.DB) error { 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) } @@ -209,6 +213,102 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, 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 From c7e1c3da69bf51ea39e54836b080fcf487bfac06 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:51:02 +1100 Subject: [PATCH 021/152] 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 --- internal/manager/config/stash_config.go | 3 ++- pkg/file/folder.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 3854c707b..7a103631c 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -42,7 +42,8 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { func (s StashConfigs) Paths() []string { paths := make([]string, len(s)) for i, c := range s { - paths[i] = c.Path + // #6618 - clean the path to ensure comparison works correctly + paths[i] = filepath.Clean(c.Path) } return paths } diff --git a/pkg/file/folder.go b/pkg/file/folder.go index e3e14186b..249f73a7a 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -33,7 +33,10 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat // 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 { - panic(fmt.Sprintf("parent path is the same as the current path: %s", 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) From c874bd560e24692d11585aff3b1397b09e8d1a4c Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:05:13 -0800 Subject: [PATCH 022/152] Fix: Custom Field Filtering (#6614) * add tests * Refactor queryBuilder: split args into per-clause fields --- pkg/sqlite/criterion_handlers.go | 2 +- pkg/sqlite/file.go | 2 +- pkg/sqlite/folder.go | 2 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/query.go | 38 +++++++++++++------- pkg/sqlite/scene.go | 2 +- pkg/sqlite/tag_test.go | 59 ++++++++++++++++++++++++++++++++ 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 943704cfe..ae245f1b5 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1129,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 { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 1be5648b4..ba925a448 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -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 } diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index 73a065cff..fdeb00913 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -600,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 } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index da1c67a10..b92a1c073 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -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 } diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 99c1f4e5f..80c7fcd40 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -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 diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 3049681b2..c2093431d 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -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 } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index b673de3f9..179969fd6 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -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 From b46fbb2e7a1002a5532129addd4e028b86a77c35 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:30:38 +0200 Subject: [PATCH 023/152] Update capitalization for sprite generation heading (#6623) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8c9aaf3f0..a25f3c765 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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.", From 681ccbf380a580daf1fa81b90ad95351e569360b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:44:20 +1100 Subject: [PATCH 024/152] Fix caption handling during scan and check before correcting path (#6634) * Handle case where folder entry exists for corrected path in correctSubFolderHierarchy * Log scan start * Handle caption files during scan --- internal/manager/task_scan.go | 115 +++++++++++++++++++--------------- pkg/file/move.go | 19 ++++++ pkg/file/scan.go | 4 ++ pkg/file/video/caption.go | 15 ++++- pkg/utils/mutex.go | 25 ++++++++ 5 files changed, 128 insertions(+), 50 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index d09765577..77a492134 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -26,6 +26,7 @@ import ( "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" ) type ScanJob struct { @@ -35,6 +36,8 @@ type ScanJob struct { fileQueue chan file.ScannedFile count int + + unmatchedCaptionFiles utils.MutexField[[]string] } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { @@ -73,6 +76,8 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)} j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)} + logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks) + j.runJob(ctx, paths, nTasks, progress) taskQueue.Close() @@ -83,7 +88,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { } elapsed := time.Since(start) - logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) + logger.Infof("Scan finished (%s)", elapsed) j.subscriptions.notify() return nil @@ -172,6 +177,22 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file. return fs.SkipDir } + // we don't include caption files in the file scan, but we do need + // to handle them + if fsutil.MatchExtension(path, video.CaptionExts) { + fileRepo := j.scanner.Repository.File + matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo) + + if !matched { + logger.Debugf("No matching video file found for caption file %s", path) + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + return append(files, path) + }) + } + + return nil + } + logger.Debugf("Skipping file %s", path) return nil } @@ -309,6 +330,45 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * return err } + // if this is a new video file, match it with any unmatched caption files + if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 { + videoFile, _ := r.File.(*models.VideoFile) + + if videoFile != nil { + // try to match any unmatched caption files to this video file + for _, captionPath := range j.unmatchedCaptionFiles.Get() { + if video.MatchesCaption(videoFile.Path, captionPath) { + video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File) + + // remove from the unmatched list + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + newFiles := make([]string, 0, len(files)-1) + for _, f := range files { + if f != captionPath { + newFiles = append(newFiles, f) + } + } + return newFiles + }) + } + } + } + } + + // clean captions - scene handler handles this as well, but + // unchanged files aren't processed by the scene handler + if r.IsUnchanged() { + videoFile, _ := r.File.(*models.VideoFile) + + if videoFile != nil { + txnMgr := j.scanner.Repository.TxnManager + fileRepo := j.scanner.Repository.File + if err := video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo); err != nil { + logger.Errorf("Error cleaning captions: %v", err) + } + } + } + // handle rename should have already handled the contents of the zip file // so shouldn't need to scan it again @@ -378,11 +438,10 @@ type sceneFinder interface { // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. type handlerRequiredFilter struct { extensionConfig - txnManager txn.Manager - SceneFinder sceneFinder - ImageFinder fileCounter - GalleryFinder galleryFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager + SceneFinder sceneFinder + ImageFinder fileCounter + GalleryFinder galleryFinder FolderCache *lru.LRU[bool] @@ -398,7 +457,6 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler SceneFinder: repo.Scene, ImageFinder: repo.Image, GalleryFinder: repo.Gallery, - CaptionUpdater: repo.File, FolderCache: lru.New[bool](processes * 2), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), } @@ -473,42 +531,12 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool } } - if isVideoFile { - // TODO - check if the cover exists - // hash := scene.GetHash(ff, f.videoFileNamingAlgorithm) - // ssPath := instance.Paths.Scene.GetScreenshotPath(hash) - // if exists, _ := fsutil.FileExists(ssPath); !exists { - // // if not, check if the file is a primary file for a scene - // scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID) - // if err != nil { - // // just ignore - // return false - // } - - // if len(scenes) > 0 { - // // if it is, then it needs to be re-generated - // return true - // } - // } - - // clean captions - scene handler handles this as well, but - // unchanged files aren't processed by the scene handler - videoFile, _ := ff.(*models.VideoFile) - if videoFile != nil { - if err := video.CleanCaptions(ctx, videoFile, f.txnManager, f.CaptionUpdater); err != nil { - logger.Errorf("Error cleaning captions: %v", err) - } - } - } - return false } type scanFilter struct { extensionConfig - txnManager txn.Manager - FileFinder models.FileFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager stashPaths config.StashConfigs generatedPath string @@ -521,8 +549,6 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim return &scanFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, - FileFinder: repo.File, - CaptionUpdater: repo.File, stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), @@ -552,15 +578,6 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) - // handle caption files - if fsutil.MatchExtension(path, video.CaptionExts) { - // we don't include caption files in the file scan, but we do need - // to handle them - video.AssociateCaptions(ctx, path, f.txnManager, f.FileFinder, f.CaptionUpdater) - - return false - } - if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { logger.Debugf("Skipping %s as it does not match any known file extensions", path) return false diff --git a/pkg/file/move.go b/pkg/file/move.go index 06605912b..1f0a5012c 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -205,6 +205,25 @@ func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter logger.Debugf("updating folder %s to %s", oldPath, correctPath) + // #6427 - ensure folder entry with new path doesn't already exist + const caseSensitive = true + existing, err := rw.FindByPath(ctx, correctPath, caseSensitive) + if err != nil { + return fmt.Errorf("finding folder by path %s: %w", correctPath, err) + } + + if existing != nil { + // this should no longer be possible, but if it does happen, log a warning + // and skip updating this folder and its subfolders + logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath) + f.ParentFolderID = nil + if err := rw.Update(ctx, f); err != nil { + return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err) + } + + continue + } + f.Path = correctPath if err := rw.Update(ctx, f); err != nil { return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index cf1b43603..467fa7f22 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -349,6 +349,10 @@ type ScanFileResult struct { Updated bool } +func (r ScanFileResult) IsUnchanged() bool { + return !r.New && !r.Renamed && !r.Updated +} + // ScanFile scans the provided file into the database, returning the scan result. func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { var r *ScanFileResult diff --git a/pkg/file/video/caption.go b/pkg/file/video/caption.go index 43723864f..a9e216acd 100644 --- a/pkg/file/video/caption.go +++ b/pkg/file/video/caption.go @@ -90,11 +90,20 @@ type CaptionUpdater interface { UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error } +// MatchesCaption returns true if the caption file matches the video file based on the filename +func MatchesCaption(videoPath, captionPath string) bool { + captionPrefix := getCaptionPrefix(captionPath) + videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "." + return captionPrefix == videoPrefix +} + // associates captions to scene/s with the same basename -func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) { +// returns true if the caption file was matched to a video file and processed, false otherwise +func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool { captionLang := getCaptionsLangFromPath(captionPath) captionPrefix := getCaptionPrefix(captionPath) + matched := false if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error { var err error files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true) @@ -117,6 +126,8 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag path := f.Base().Path logger.Debugf("Matched captions to file %s", path) + matched = true + captions, er := w.GetCaptions(ctx, fileID) if er == nil { fileExt := filepath.Ext(captionPath) @@ -139,6 +150,8 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag }); err != nil { logger.Error(err.Error()) } + + return matched } // CleanCaptions removes non existent/accessible language codes from captions diff --git a/pkg/utils/mutex.go b/pkg/utils/mutex.go index 212200214..47439e32b 100644 --- a/pkg/utils/mutex.go +++ b/pkg/utils/mutex.go @@ -1,5 +1,7 @@ package utils +import "sync" + // MutexManager manages access to mutexes using a mutex type and key. type MutexManager struct { mapChan chan map[string]<-chan struct{} @@ -62,3 +64,26 @@ func (csm *MutexManager) Claim(mutexType string, key string, done <-chan struct{ csm.mapChan <- m }() } + +type MutexField[T any] struct { + mutex sync.RWMutex + value T +} + +func (mf *MutexField[T]) Get() T { + mf.mutex.RLock() + defer mf.mutex.RUnlock() + return mf.value +} + +func (mf *MutexField[T]) Set(value T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = value +} + +func (mf *MutexField[T]) SetFunc(f func(T) T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = f(mf.value) +} From bc75d47f15eece4648a547bda9ffa4759f68da77 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 21:45:33 -0600 Subject: [PATCH 025/152] Fix edit modal not opening inside gallery view (#6629) * Fix edit modal not opening inside gallery view The modal element was only rendered in the sidebar layout branch, but gallery images use the non-sidebar path which returned content without the modal. Also stabilize onEdit/onDelete with useCallback and add missing dependency array to the Mousetrap useEffect. Closes #6624 * Render modal once above sidebar conditional Move {modal} above the withSidebar ternary so it is rendered exactly once, avoiding the duplication that caused the original bug. Co-Authored-By: Claude Opus 4.6 --- ui/v2.5/src/components/Images/ImageList.tsx | 116 ++++++++++---------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index cc8aa48f7..f47990c4c 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -587,25 +587,6 @@ export const FilteredImageList = PatchComponent( 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, @@ -628,23 +609,42 @@ export const FilteredImageList = PatchComponent( ); } - function onEdit() { + const onEdit = useCallback(() => { showModal( ); - } + }, [showModal, selectedItems, onCloseEditDelete]); - function onDelete() { + const onDelete = useCallback(() => { showModal( ); - } + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }, [hasSelection, onEdit, onDelete]); const convertedExtraOperations: IListFilterOperation[] = providedOperations.map((o) => ({ @@ -786,41 +786,47 @@ export const FilteredImageList = PatchComponent( ); - if (!withSidebar) { - return content; - } - return ( -
+ <> {modal} - - - - setShowSidebar(false)}> - setShowSidebar(false)} - count={cachedResult.loading ? undefined : totalCount} - focus={searchFocus} - /> - - setShowSidebar(!showSidebar)} + {!withSidebar ? ( + content + ) : ( +
+ - {content} - - - -
+ + setShowSidebar(false)} + > + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + +
+
+ )} + ); } ); From 09e2b2bd4e516d52e65d66d8ce2e04100da93b59 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:45:37 +1100 Subject: [PATCH 026/152] Wrap CleanCaptions with database. Refactor AssociateCaptions. --- internal/manager/task_scan.go | 4 +++- pkg/file/video/caption.go | 32 ++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 77a492134..5d1f063c2 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -363,7 +363,9 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * if videoFile != nil { txnMgr := j.scanner.Repository.TxnManager fileRepo := j.scanner.Repository.File - if err := video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo); err != nil { + if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error { + return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo) + }); err != nil { logger.Errorf("Error cleaning captions: %v", err) } } diff --git a/pkg/file/video/caption.go b/pkg/file/video/caption.go index a9e216acd..46317d90c 100644 --- a/pkg/file/video/caption.go +++ b/pkg/file/video/caption.go @@ -129,21 +129,25 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag matched = true captions, er := w.GetCaptions(ctx, fileID) - if er == nil { - fileExt := filepath.Ext(captionPath) - ext := fileExt[1:] - if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present - newCaption := &models.VideoCaption{ - LanguageCode: captionLang, - Filename: filepath.Base(captionPath), - CaptionType: ext, - } - captions = append(captions, newCaption) - er = w.UpdateCaptions(ctx, fileID, captions) - if er == nil { - logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) - } + if er != nil { + return fmt.Errorf("getting captions for file %s: %w", path, er) + } + + fileExt := filepath.Ext(captionPath) + ext := fileExt[1:] + if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present + newCaption := &models.VideoCaption{ + LanguageCode: captionLang, + Filename: filepath.Base(captionPath), + CaptionType: ext, } + captions = append(captions, newCaption) + er = w.UpdateCaptions(ctx, fileID, captions) + if er != nil { + return fmt.Errorf("updating captions for file %s: %w", path, er) + } + + logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) } } return err From 784795660ba07e1f6fecd1dd0b9ebbc2cbf535f7 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 22:47:23 -0600 Subject: [PATCH 027/152] Skip scanning zip contents when fingerprint is unchanged (#6633) * Skip scanning zip contents when fingerprint is unchanged When a zip-based gallery's modification time changes but its content hash (oshash/md5) remains the same, skip walking and rescanning every file inside the zip. This avoids expensive per-file fingerprint recalculation when zip metadata changes without actual content changes. Closes #6512 * Log a debug message when skipping a zip scan due to unchanged fingerprint --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- internal/manager/task_scan.go | 8 ++++++-- pkg/file/scan.go | 17 +++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 5d1f063c2..cf675a5af 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -372,9 +372,11 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * } // handle rename should have already handled the contents of the zip file - // so shouldn't need to scan it again + // so shouldn't need to scan it again. + // Only scan zip contents if the file is new, the fingerprint changed, + // or if a force rescan was requested. - if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) { + if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) { ff := r.File f.BaseFile = ff.Base() @@ -386,6 +388,8 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * if err := j.scanZipFile(zipCtx, f, progress); err != nil { logger.Errorf("Error scanning zip file %q: %v", f.Path, err) } + } else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) { + logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path) } return nil diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 467fa7f22..8ff51b359 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -343,10 +343,11 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing } type ScanFileResult struct { - File models.File - New bool - Renamed bool - Updated bool + File models.File + New bool + Renamed bool + Updated bool + FingerprintChanged bool } func (r ScanFileResult) IsUnchanged() bool { @@ -791,6 +792,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } + oldFingerprints := existing.Base().Fingerprints + fingerprintChanged := fp.ContentsChanged(oldFingerprints) + s.removeOutdatedFingerprints(existing, fp) existing.SetFingerprints(fp) @@ -814,8 +818,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } return &ScanFileResult{ - File: existing, - Updated: true, + File: existing, + Updated: true, + FingerprintChanged: fingerprintChanged, }, nil } From b8dff736963094712368a94512a65e1ce2065f74 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 22:47:43 -0600 Subject: [PATCH 028/152] Fix datepicker button border radius in input groups (#6630) Add missing .input-group-append .btn border-radius rule to zero out the left-side radius, matching the existing .input-group-prepend rule. Fixes #6518 Co-authored-by: Claude Opus 4.6 --- ui/v2.5/src/components/Shared/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 1251ffb9b..f2881fc55 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -446,6 +446,13 @@ button.collapse-button { } } +.input-group-append { + .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } +} + .ModalComponent .modal-footer { justify-content: space-between; } From 52bd9392fbb5ea4a5307de0c06363ffef0f583fb Mon Sep 17 00:00:00 2001 From: Abdu Dihan Date: Mon, 2 Mar 2026 04:53:02 +0000 Subject: [PATCH 029/152] Fix stale browser-cached thumbnails after file content changes during scan. (#6622) * Fix stale thumbnails after file content changes When a file's content changed (e.g. after renaming files in a gallery), the scan handler updated fingerprints but did not bump the entity's updated_at timestamp. Since thumbnail URLs use updated_at as a cache buster and are served with immutable/1-year cache headers, browsers would indefinitely serve the old cached thumbnail. Update image, scene, and gallery scan handlers to call UpdatePartial (which sets updated_at to now) whenever file content changes, not only when a new file association is created. --- pkg/gallery/scan.go | 9 +-- pkg/gallery/scan_test.go | 108 +++++++++++++++++++++++++++++++ pkg/image/scan.go | 6 +- pkg/image/scan_test.go | 120 +++++++++++++++++++++++++++++++++++ pkg/models/mocks/database.go | 11 ++++ pkg/scene/scan.go | 6 +- pkg/scene/scan_test.go | 114 +++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 pkg/gallery/scan_test.go create mode 100644 pkg/image/scan_test.go create mode 100644 pkg/scene/scan_test.go diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 2064355cd..b3e5d2c3c 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -135,13 +135,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil { return fmt.Errorf("adding file to gallery: %w", err) } - // update updated_at time - if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { - return fmt.Errorf("updating gallery: %w", err) - } } if !found || updateExisting { + // update updated_at time when file association or content changes + if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { + return fmt.Errorf("updating gallery: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil) } } diff --git a/pkg/gallery/scan_test.go b/pkg/gallery/scan_test.go new file mode 100644 index 000000000..4a89206e3 --- /dev/null +++ b/pkg/gallery/scan_test.go @@ -0,0 +1,108 @@ +package gallery + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testGalleryID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"} + + makeGallery := func() *models.Gallery { + return &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + + if tt.expectUpdate { + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) + } else { + db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testGalleryID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"} + + gallery := &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil) + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false) + assert.NoError(t, err) + }) + + db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)) + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) +} diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 317e3605f..99b31f698 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -210,8 +210,8 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. changed = true } - if changed { - // always update updated_at time + if changed || updateExisting { + // update updated_at time when file association or content changes imagePartial := models.NewImagePartial() imagePartial.GalleryIDs = galleryIDs @@ -229,9 +229,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return fmt.Errorf("updating gallery updated at timestamp: %w", err) } } - } - if changed || updateExisting { h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil) } } diff --git a/pkg/image/scan_test.go b/pkg/image/scan_test.go new file mode 100644 index 000000000..f48c188ee --- /dev/null +++ b/pkg/image/scan_test.go @@ -0,0 +1,120 @@ +package image + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockScanConfig struct{} + +func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false } + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testImageID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"} + + makeImage := func() *models.Image { + return &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + + if tt.expectUpdate { + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) + } else { + db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testImageID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"} + + image := &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil) + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{image}, newFile, false) + assert.NoError(t, err) + }) + + db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID)) + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) +} diff --git a/pkg/models/mocks/database.go b/pkg/models/mocks/database.go index ec4177b30..88f106e19 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -3,6 +3,7 @@ package mocks import ( "context" + "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" @@ -89,6 +90,16 @@ func (db *Database) AssertExpectations(t mock.TestingT) { db.SavedFilter.AssertExpectations(t) } +// WithTxnCtx runs fn with a context that has a transaction hook manager registered, +// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic. +// Always rolls back to avoid executing the registered hooks. +func (db *Database) WithTxnCtx(fn func(ctx context.Context)) { + _ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error { + fn(ctx) + return errors.New("rollback") + }) +} + func (db *Database) Repository() models.Repository { return models.Repository{ TxnManager: db, diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index e1038fbc3..c70c44a9e 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -160,15 +160,15 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil { return fmt.Errorf("adding file to scene: %w", err) } + } - // update updated_at time + if !found || updateExisting { + // update updated_at time when file association or content changes scenePartial := models.NewScenePartial() if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil { return fmt.Errorf("updating scene: %w", err) } - } - if !found || updateExisting { h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil) } } diff --git a/pkg/scene/scan_test.go b/pkg/scene/scan_test.go new file mode 100644 index 000000000..71729bb57 --- /dev/null +++ b/pkg/scene/scan_test.go @@ -0,0 +1,114 @@ +package scene + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testSceneID = 1 + testFileID = 100 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, + } + + makeScene := func() *models.Scene { + return &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + + if tt.expectUpdate { + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) + } else { + db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testSceneID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, + } + newFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, + } + + scene := &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil) + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false) + assert.NoError(t, err) + }) + + db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)) + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) +} From 99a0d01371eb39674c3e01346d1cbe0a4f6be2a4 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:11:55 -0800 Subject: [PATCH 030/152] Fix new panic in IsFsPathCaseSensitive: Use filepath operations to check for file system case sensitivity (#6635) * Use filepath operations to check for file system case sensitivity --- pkg/fsutil/fs.go | 13 ++----------- pkg/fsutil/fs_test.go | 11 +++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/fsutil/fs.go b/pkg/fsutil/fs.go index 10666bb63..032bec53c 100644 --- a/pkg/fsutil/fs.go +++ b/pkg/fsutil/fs.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "unicode" ) @@ -27,18 +26,10 @@ func IsFsPathCaseSensitive(path string) (bool, error) { if err != nil { // cannot be case flipped return false, err } - i := strings.LastIndex(path, base) - if i < 0 { // shouldn't happen - return false, fmt.Errorf("could not case flip path %s", path) - } - flipped := []rune(path) - for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part ) - flipped[i] = c - i++ - } + flippedPath := filepath.Join(filepath.Dir(path), fBase) - fiCase, err := os.Stat(string(flipped)) + fiCase, err := os.Stat(flippedPath) if err != nil { // cannot stat the case flipped path return true, nil // fs of path should be case sensitive } diff --git a/pkg/fsutil/fs_test.go b/pkg/fsutil/fs_test.go index 522e95fa6..155e76ba5 100644 --- a/pkg/fsutil/fs_test.go +++ b/pkg/fsutil/fs_test.go @@ -41,4 +41,15 @@ func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) { } // assert.True(t, r, "expected fs to be case sensitive") + + // Ensure that subfolders of a folder with multi-byte chars is not causing a panic + path3 := filepath.Join(dir, "NoPanic ❤️") + makeDir(path3) + path4 := filepath.Join(path3, "Test") + makeDir(path4) + + _, err = IsFsPathCaseSensitive(path4) + if err != nil { + t.Fatal(err) + } } From b9baa7ea9f0b737745fc500f7584442aeb116ebd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:26:04 +1100 Subject: [PATCH 031/152] Fix gallery image list styling --- ui/v2.5/src/components/Galleries/styles.scss | 5 +++++ ui/v2.5/src/components/Images/ImageList.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index ac9330e9a..b59da415e 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -182,6 +182,11 @@ $galleryTabWidth: 450px; width: 100%; } +@media (min-width: 1200px) { + .gallery-container .image-list .filtered-list-toolbar.has-selection { + top: 0; + } +} @media (min-width: 1200px), (max-width: 575px) { .gallery-performers { .performer-card { diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index f47990c4c..50956b497 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -790,7 +790,7 @@ export const FilteredImageList = PatchComponent( <> {modal} {!withSidebar ? ( - content +
{content}
) : (
Date: Mon, 2 Mar 2026 14:11:28 -0800 Subject: [PATCH 032/152] Add Selective generate (#6621) --- graphql/schema/types/metadata.graphql | 2 + internal/manager/manager_tasks.go | 22 +++++++ internal/manager/task_generate.go | 32 +++++++--- pkg/image/query.go | 31 ++++++++++ .../Settings/Tasks/LibraryTasks.tsx | 61 ++++++++++++++++--- ui/v2.5/src/locales/en-GB.json | 1 + 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 27cbb86fb..e2601150b 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -26,6 +26,8 @@ input GenerateMetadataInput { imageIDs: [ID!] "gallery ids to generate for" galleryIDs: [ID!] + "paths to run generate on, in addition to the other ID lists" + paths: [String!] "overwrite existing media" overwrite: Boolean diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index e84fda9b9..c9e840519 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -74,6 +74,28 @@ func getScanPaths(inputPaths []string) []*config.StashConfig { return ret } +// Filters the input array for paths that are within the paths managed by stash +func filterStashPaths(inputPaths []string) []string { + if len(inputPaths) == 0 { + return inputPaths + } + + stashPaths := config.GetInstance().GetStashPaths() + + var ret []string + for _, p := range inputPaths { + s := stashPaths.GetStashFromDirPath(p) + if s == nil { + logger.Warnf("%s is not in the configured stash paths", p) + continue + } + + ret = append(ret, p) + } + + return ret +} + // ScanSubscribe subscribes to a notification that is triggered when a // scan or clean is complete. func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool { diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index cc991d5d6..f2aab2b3c 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -43,6 +43,8 @@ type GenerateMetadataInput struct { GalleryIDs []string `json:"galleryIDs"` // overwrite existing media Overwrite bool `json:"overwrite"` + // paths to run generate on, in addition to the other ID lists + Paths []string `json:"paths"` } type GeneratePreviewOptionsInput struct { @@ -133,8 +135,13 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene - if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 { - j.queueTasks(ctx, g, queue) + if len(j.input.SceneIDs) == 0 && + len(j.input.MarkerIDs) == 0 && + len(j.input.ImageIDs) == 0 && + len(j.input.GalleryIDs) == 0 && + len(j.input.Paths) == 0 { + + j.queueTasks(ctx, g, nil, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(ctx, sceneIDs) @@ -183,6 +190,11 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error } } } + + if len(j.input.Paths) > 0 { + paths := filterStashPaths(j.input.Paths) + j.queueTasks(ctx, g, paths, queue) + } } return nil @@ -276,17 +288,18 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error return nil } -func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { j.totals = totalsGenerate{} - j.queueScenesTasks(ctx, g, queue) - j.queueImagesTasks(ctx, g, queue) + j.queueScenesTasks(ctx, g, paths, queue) + j.queueImagesTasks(ctx, g, paths, queue) } -func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + sceneFilter := scene.FilterFromPaths(paths) r := j.repository @@ -295,7 +308,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato return } - scenes, err := scene.Query(ctx, r.Scene, nil, findFilter) + scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return @@ -322,10 +335,11 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato } } -func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + imageFilter := image.FilterFromPaths(paths) r := j.repository @@ -334,7 +348,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato return } - images, err := image.Query(ctx, r.Image, nil, findFilter) + images, err := image.Query(ctx, r.Image, imageFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return diff --git a/pkg/image/query.go b/pkg/image/query.go index b9b9e6628..958c9de9b 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -2,7 +2,9 @@ package image import ( "context" + "path/filepath" "strconv" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -46,6 +48,35 @@ func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, return images, nil } +// FilterFromPaths creates a ImageFilterType that filters using the provided +// paths. +func FilterFromPaths(paths []string) *models.ImageFilterType { + ret := &models.ImageFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range paths { + if !strings.HasSuffix(p, sep) { + p += sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.ImageFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) { filter := &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 605e37933..6e9c13ea4 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -79,6 +79,7 @@ export const LibraryTasks: React.FC = () => { scan: false, autoTag: false, identify: false, + generate: false, }); function getDefaultScanOptions(): GQL.ScanMetadataInput { @@ -265,6 +266,41 @@ export const LibraryTasks: React.FC = () => { ); } + function renderGenerateDialog() { + if (!dialogOpen.generate) { + return; + } + + return ; + } + + function onGenerateDialogClosed(paths?: string[]) { + if (paths) { + runGenerate(paths); + } + + setDialogOpen({ generate: false }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function runGenerate(paths?: string[]) { + try { + await mutateMetadataGenerate({ + ...generateOptions, + paths, + }); + + Toast.success( + intl.formatMessage( + { id: "config.tasks.added_job_to_queue" }, + { operation_name: intl.formatMessage({ id: "actions.generate" }) } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function onGenerateClicked() { try { // insert preview options here instead of loading them @@ -307,6 +343,7 @@ export const LibraryTasks: React.FC = () => { {renderScanDialog()} {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} + {renderGenerateDialog()} { subHeadingID: "config.tasks.generate_desc", }} topLevel={ - + <> + + + } collapsible > diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index a25f3c765..56b11f3f9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -123,6 +123,7 @@ "invert_selection": "Invert Selection", "selective_auto_tag": "Selective auto tag", "selective_clean": "Selective clean", + "selective_generate": "Selective generate", "selective_scan": "Selective scan", "set_as_default": "Set as default", "set_back_image": "Back image…", From cd0980201c6eda6046f8a61f8a34cf98603fed0f Mon Sep 17 00:00:00 2001 From: Matt Stone Date: Wed, 4 Mar 2026 07:17:14 +1000 Subject: [PATCH 033/152] feat: Add .stashignore support for gitignore-style scan exclusions (#6485) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + internal/manager/scan_stashignore_test.go | 268 +++++++++++ internal/manager/task_clean.go | 11 +- internal/manager/task_scan.go | 8 + pkg/file/stashignore.go | 255 +++++++++++ pkg/file/stashignore_test.go | 523 ++++++++++++++++++++++ ui/v2.5/src/docs/en/Manual/Tasks.md | 33 ++ 8 files changed, 1099 insertions(+), 2 deletions(-) create mode 100644 internal/manager/scan_stashignore_test.go create mode 100644 pkg/file/stashignore.go create mode 100644 pkg/file/stashignore_test.go diff --git a/go.mod b/go.mod index db0d6fe34..348036710 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.6 diff --git a/go.sum b/go.sum index dbe82cf99..4e19720f5 100644 --- a/go.sum +++ b/go.sum @@ -537,6 +537,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= diff --git a/internal/manager/scan_stashignore_test.go b/internal/manager/scan_stashignore_test.go new file mode 100644 index 000000000..fafd246e8 --- /dev/null +++ b/internal/manager/scan_stashignore_test.go @@ -0,0 +1,268 @@ +//go:build integration +// +build integration + +package manager + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stashapp/stash/pkg/file" + + // Necessary to register custom migrations. + _ "github.com/stashapp/stash/pkg/sqlite/migrations" +) + +// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing. +// It provides a fixed library root for the filter. +type stashIgnorePathFilter struct { + filter *file.StashIgnoreFilter + libraryRoot string +} + +func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { + return f.filter.Accept(ctx, path, info, f.libraryRoot) +} + +// createTestFileOnDisk creates a file with some content. +func createTestFileOnDisk(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + // Write some content so the file has a non-zero size. + if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } + return path +} + +// createStashIgnoreFile creates a .stashignore file with the given content. +func createStashIgnoreFile(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, ".stashignore") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create .stashignore: %v", err) + } +} + +func TestScannerWithStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "ignore_me.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4") + createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4") + createTestFileOnDisk(t, tmpDir, "temp/processing.mp4") + + // Create .stashignore file. + stashignore := `# Ignore specific files +ignore_me.mp4 +subdir/skip_this.mp4 + +# Ignore directories +excluded_dir/ +temp/ +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "ignore_me.mp4"), false}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + {filepath.Join(tmpDir, "subdir/skip_this.mp4"), false}, + {filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false}, + {filepath.Join(tmpDir, "temp/processing.mp4"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNestedStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "root.mp4") + createTestFileOnDisk(t, tmpDir, "root.tmp") + createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/sub.log") + createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp") + + // Root .stashignore excludes *.tmp. + createStashIgnoreFile(t, tmpDir, "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n") + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "root.mp4"), true}, + {filepath.Join(tmpDir, "root.tmp"), false}, + {filepath.Join(tmpDir, "subdir/sub.mp4"), true}, + {filepath.Join(tmpDir, "subdir/sub.log"), false}, + {filepath.Join(tmpDir, "subdir/sub.tmp"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithoutStashIgnore(t *testing.T) { + // Create temp directory structure (no .stashignore). + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + + // Create stashignore filter with library root (but no .stashignore file exists). + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNegationPattern(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "file1.tmp") + createTestFileOnDisk(t, tmpDir, "file2.tmp") + createTestFileOnDisk(t, tmpDir, "keep_this.tmp") + createTestFileOnDisk(t, tmpDir, "video.mp4") + + // Create .stashignore with negation. + stashignore := `*.tmp +!keep_this.tmp +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "file1.tmp"), false}, + {filepath.Join(tmpDir, "file2.tmp"), false}, + {filepath.Join(tmpDir, "keep_this.tmp"), true}, + {filepath.Join(tmpDir, "video.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index ddd86e2f2..9a20b3990 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -154,6 +154,7 @@ func newCleanFilter(c *config.Config) *cleanFilter { generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), + stashIgnoreFilter: file.NewStashIgnoreFilter(), }, } } @@ -173,12 +174,18 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } if stash == nil { - logger.Infof("%s not in any stash library directories. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path) return false } if fsutil.IsPathInDir(generatedPath, path) { - logger.Infof("%s is in generated path. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path) + return false + } + + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path) { + logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path) return false } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index cf675a5af..a006abbf8 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -549,6 +549,7 @@ type scanFilter struct { videoExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time + stashIgnoreFilter *file.StashIgnoreFilter } func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { @@ -560,6 +561,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), minModTime: minModTime, + stashIgnoreFilter: file.NewStashIgnoreFilter(), } } @@ -580,6 +582,12 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path) { + logger.Debugf("Skipping %s due to .stashignore", path) + return false + } + isVideoFile := useAsVideo(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) diff --git a/pkg/file/stashignore.go b/pkg/file/stashignore.go new file mode 100644 index 000000000..160b5c224 --- /dev/null +++ b/pkg/file/stashignore.go @@ -0,0 +1,255 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + lru "github.com/hashicorp/golang-lru/v2" + ignore "github.com/sabhiram/go-gitignore" + "github.com/stashapp/stash/pkg/logger" +) + +const stashIgnoreFilename = ".stashignore" + +// entriesCacheSize is the size of the LRU cache for collected ignore entries. +// This cache stores the computed list of ignore entries per directory, avoiding +// repeated directory tree walks for files in the same directory. +const entriesCacheSize = 500 + +// StashIgnoreFilter implements PathFilter to exclude files/directories +// based on .stashignore files with gitignore-style patterns. +type StashIgnoreFilter struct { + // cache stores compiled ignore patterns per directory. + cache sync.Map // map[string]*ignoreEntry + // entriesCache stores collected ignore entries per (dir, libraryRoot) pair. + // This avoids recomputing the entry list for every file in the same directory. + entriesCache *lru.Cache[string, []*ignoreEntry] +} + +// ignoreEntry holds the compiled ignore patterns for a directory. +type ignoreEntry struct { + // patterns is the compiled gitignore matcher for this directory. + patterns *ignore.GitIgnore + // dir is the directory this entry applies to. + dir string +} + +// NewStashIgnoreFilter creates a new StashIgnoreFilter. +func NewStashIgnoreFilter() *StashIgnoreFilter { + // Create the LRU cache for collected entries. + // Ignore error as it only fails if size <= 0. + entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize) + return &StashIgnoreFilter{ + entriesCache: entriesCache, + } +} + +// Accept returns true if the path should be included in the scan. +// It checks for .stashignore files in the directory hierarchy and +// applies gitignore-style pattern matching. +// The libraryRoot parameter bounds the search for .stashignore files - +// only directories within the library root are checked. +func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string) bool { + // If no library root provided, accept the file (safety fallback). + if libraryRoot == "" { + return true + } + + // Get the directory containing this path. + dir := filepath.Dir(path) + + // Collect all applicable ignore entries from library root to this directory. + entries := f.collectIgnoreEntries(dir, libraryRoot) + + // If no .stashignore files found, accept the file. + if len(entries) == 0 { + return true + } + + // Check each ignore entry in order (from root to most specific). + // Later entries can override earlier ones with negation patterns. + ignored := false + for _, entry := range entries { + // Get path relative to the ignore file's directory. + entryRelPath, err := filepath.Rel(entry.dir, path) + if err != nil { + continue + } + entryRelPath = filepath.ToSlash(entryRelPath) + if info.IsDir() { + entryRelPath += "/" + } + + if entry.patterns.MatchesPath(entryRelPath) { + ignored = true + } + } + + return !ignored +} + +// collectIgnoreEntries gathers all ignore entries from library root to the given directory. +// It walks up the directory tree from dir to libraryRoot and returns entries in order +// from root to most specific. Results are cached to avoid repeated computation for +// files in the same directory. +func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry { + // Clean paths for consistent comparison and cache key generation. + dir = filepath.Clean(dir) + libraryRoot = filepath.Clean(libraryRoot) + + // Build cache key from dir and libraryRoot. + cacheKey := dir + "\x00" + libraryRoot + + // Check the entries cache first. + if cached, ok := f.entriesCache.Get(cacheKey); ok { + return cached + } + + // Try subdirectory shortcut: if parent's entries are cached, extend them. + if dir != libraryRoot { + parent := filepath.Dir(dir) + if isPathInOrEqual(libraryRoot, parent) { + parentKey := parent + "\x00" + libraryRoot + if parentEntries, ok := f.entriesCache.Get(parentKey); ok { + // Parent is cached - just check if current dir has a .stashignore. + entries := parentEntries + if entry := f.getOrLoadIgnoreEntry(dir); entry != nil { + // Copy parent slice and append to avoid mutating cached slice. + entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1) + copy(entries, parentEntries) + entries = append(entries, entry) + } + f.entriesCache.Add(cacheKey, entries) + return entries + } + } + } + + // No cache hit - compute from scratch. + // Walk up from dir to library root, collecting directories. + var dirs []string + current := dir + for { + // Check if we're still within the library root. + if !isPathInOrEqual(libraryRoot, current) { + break + } + + dirs = append(dirs, current) + + // Stop if we've reached the library root. + if current == libraryRoot { + break + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding library root. + break + } + current = parent + } + + // Reverse to get root-to-leaf order. + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { + dirs[i], dirs[j] = dirs[j], dirs[i] + } + + // Check each directory for .stashignore files. + var entries []*ignoreEntry + for _, d := range dirs { + if entry := f.getOrLoadIgnoreEntry(d); entry != nil { + entries = append(entries, entry) + } + } + + // Cache the result. + f.entriesCache.Add(cacheKey, entries) + + return entries +} + +// isPathInOrEqual checks if path is equal to or inside root. +func isPathInOrEqual(root, path string) bool { + if path == root { + return true + } + // Check if path starts with root + separator. + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it. +func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry { + // Check cache first. + if cached, ok := f.cache.Load(dir); ok { + entry := cached.(*ignoreEntry) + if entry.patterns == nil { + return nil // Cached negative result. + } + return entry + } + + // Try to load .stashignore from this directory. + stashIgnorePath := filepath.Join(dir, stashIgnoreFilename) + patterns, err := f.loadIgnoreFile(stashIgnorePath) + if err != nil { + if !os.IsNotExist(err) { + logger.Warnf("Failed to load .stashignore from %s: %v", dir, err) + } + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + if patterns == nil { + // File exists but has no patterns (empty or only comments). + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + + logger.Debugf("Loaded .stashignore from %s", dir) + + entry := &ignoreEntry{ + patterns: patterns, + dir: dir, + } + f.cache.Store(dir, entry) + return entry +} + +// loadIgnoreFile loads and compiles a .stashignore file. +func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + var patterns []string + + for _, line := range lines { + // Trim trailing whitespace (but preserve leading for patterns). + line = strings.TrimRight(line, " \t\r") + + // Skip empty lines. + if line == "" { + continue + } + + // Skip comments (but not escaped #). + if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") { + continue + } + + patterns = append(patterns, line) + } + + if len(patterns) == 0 { + // File exists but has no patterns (e.g., only comments). + return nil, nil + } + + return ignore.CompileIgnoreLines(patterns...), nil +} diff --git a/pkg/file/stashignore_test.go b/pkg/file/stashignore_test.go new file mode 100644 index 000000000..5297f544b --- /dev/null +++ b/pkg/file/stashignore_test.go @@ -0,0 +1,523 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "testing" +) + +// Helper to create an empty file. +func createTestFile(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a file with content. +func createTestFileWithContent(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a directory. +func createTestDir(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("failed to create directory %s: %v", path, err) + } +} + +// walkAndFilter walks the directory tree and returns paths accepted by the filter. +// Returns paths relative to root for easier assertion. +func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string { + t.Helper() + var accepted []string + ctx := context.Background() + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself. + if path == root { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if filter.Accept(ctx, path, info, root) { + relPath, _ := filepath.Rel(root, path) + accepted = append(accepted, relPath) + } else if info.IsDir() { + // If directory is rejected, skip it. + return filepath.SkipDir + } + + return nil + }) + + if err != nil { + t.Fatalf("walk failed: %v", err) + } + + sort.Strings(accepted) + return accepted +} + +// assertPathsEqual checks that the accepted paths match expected. +func assertPathsEqual(t *testing.T, expected, actual []string) { + t.Helper() + sort.Strings(expected) + + if len(expected) != len(actual) { + t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual) + return + } + + for i := range expected { + if expected[i] != actual[i] { + t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i]) + } + } +} + +func TestStashIgnore_ExactFilename(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore that excludes exact filename. + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_WildcardPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "temp1.tmp") + createTestFile(t, tmpDir, "temp2.tmp") + createTestFile(t, tmpDir, "notes.log") + + // Create .stashignore that excludes by extension. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DirectoryExclusion(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "excluded_dir") + createTestFile(t, tmpDir, "excluded_dir/video2.mp4") + createTestFile(t, tmpDir, "excluded_dir/video3.mp4") + createTestDir(t, tmpDir, "included_dir") + createTestFile(t, tmpDir, "included_dir/video4.mp4") + + // Create .stashignore that excludes a directory. + createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "included_dir", + "included_dir/video4.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NegationPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "file1.tmp") + createTestFile(t, tmpDir, "file2.tmp") + createTestFile(t, tmpDir, "keep_this.tmp") + + // Create .stashignore that excludes *.tmp but keeps one. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "keep_this.tmp", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore with comments and empty lines. + stashignore := `# This is a comment +ignore_me.mp4 + +# Another comment + +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "root_video.mp4") + createTestFile(t, tmpDir, "root_ignore.tmp") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/sub_video.mp4") + createTestFile(t, tmpDir, "subdir/sub_ignore.log") + createTestFile(t, tmpDir, "subdir/also_tmp.tmp") + + // Root .stashignore excludes *.tmp. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // *.tmp from root should apply everywhere. + // *.log from subdir should only apply in subdir. + expected := []string{ + ".stashignore", + "root_video.mp4", + "subdir", + "subdir/.stashignore", + "subdir/sub_video.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_PathPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video2.mp4") + createTestFile(t, tmpDir, "subdir/skip_this.mp4") + + // Create .stashignore that excludes a specific path. + createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "subdir", + "subdir/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DoubleStarPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "a") + createTestFile(t, tmpDir, "a/video2.mp4") + createTestDir(t, tmpDir, "a/temp") + createTestFile(t, tmpDir, "a/temp/video3.mp4") + createTestDir(t, tmpDir, "a/b") + createTestDir(t, tmpDir, "a/b/temp") + createTestFile(t, tmpDir, "a/b/temp/video4.mp4") + + // Create .stashignore that excludes temp directories at any level. + createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "a", + "a/b", + "a/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_LeadingSlashPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "ignore.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/ignore.mp4") + + // Create .stashignore that excludes only at root level. + createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only root ignore.mp4 should be excluded. + expected := []string{ + ".stashignore", + "subdir", + "subdir/ignore.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NoStashIgnoreFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files without any .stashignore. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video3.mp4") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // All files should be accepted. + expected := []string{ + "subdir", + "subdir/video3.mp4", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_HiddenDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files including hidden directory. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, ".hidden") + createTestFile(t, tmpDir, ".hidden/video2.mp4") + + // Create .stashignore that excludes hidden directories. + createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "file.tmp") + createTestFile(t, tmpDir, "file.log") + createTestFile(t, tmpDir, "file.bak") + + // Each pattern should be on its own line. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_TrailingSpaces(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Pattern with trailing spaces (should be trimmed). + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_EscapedHash(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "#filename.mp4") + + // Escaped hash should match literal # character. + createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CaseSensitiveMatching(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files - use distinct names that work on all filesystems. + createTestFile(t, tmpDir, "video_lower.mp4") + createTestFile(t, tmpDir, "VIDEO_UPPER.mp4") + createTestFile(t, tmpDir, "other.avi") + + // Pattern should match exactly (case-sensitive). + createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only exact match is excluded. + expected := []string{ + ".stashignore", + "VIDEO_UPPER.mp4", + "other.avi", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_ComplexScenario(t *testing.T) { + tmpDir := t.TempDir() + + // Create a complex directory structure. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.avi") + createTestFile(t, tmpDir, "thumbnail.jpg") + createTestFile(t, tmpDir, "metadata.nfo") + createTestDir(t, tmpDir, "movies") + createTestFile(t, tmpDir, "movies/movie1.mp4") + createTestFile(t, tmpDir, "movies/movie1.nfo") + createTestDir(t, tmpDir, "movies/.thumbnails") + createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg") + createTestDir(t, tmpDir, "temp") + createTestFile(t, tmpDir, "temp/processing.mp4") + createTestDir(t, tmpDir, "backup") + createTestFile(t, tmpDir, "backup/video1.mp4.bak") + + // Complex .stashignore. + stashignore := `# Ignore metadata files +*.nfo + +# Ignore hidden directories +.* +!.stashignore + +# Ignore temp and backup directories +temp/ +backup/ + +# But keep thumbnails in specific location +!movies/.thumbnails/ +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "movies", + "movies/.thumbnails", + "movies/.thumbnails/thumb1.jpg", + "movies/movie1.mp4", + "thumbnail.jpg", + "video1.mp4", + "video2.avi", + } + + assertPathsEqual(t, expected, accepted) +} diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 4191afd24..584759b09 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -10,6 +10,39 @@ Stash currently identifies files by performing a quick file hash. This means tha Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used. +### Ignoring Files with .stashignore + +You can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax. + +Place a `.stashignore` file in any directory within your library. The patterns in that file will apply to all files and subdirectories within that directory. You can have multiple `.stashignore` files at different levels of your directory hierarchy - patterns from parent directories cascade down to child directories. + +**Supported patterns:** + +| Pattern | Description | +|---------|-------------| +| `filename.mp4` | Ignore a specific file. | +| `*.tmp` | Ignore all files with a specific extension. | +| `temp/` | Ignore a directory and all its contents. | +| `**/cache/` | Ignore directories named "cache" at any level. | +| `!important.mp4` | Negation - do not ignore this file even if it matches a previous pattern. | +| `# comment` | Lines starting with # are comments. | +| `\#filename` | Use backslash to match a literal # character. | + +**Example .stashignore file:** + +``` +# Ignore temporary files +*.tmp +*.log + +# Ignore specific directories +temp/ +.thumbnails/ + +# But keep this specific file +!important.tmp +``` + The scan task accepts the following options: | Option | Description | From f7da37400bac091f2eb706909865d569dbac9775 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:10:07 +1100 Subject: [PATCH 034/152] Fix preview scrubber scaling on smaller sizes (#6640) --- .../src/components/Scenes/PreviewScrubber.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 8c9d3097d..e60c638d7 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -46,6 +46,18 @@ export const PreviewScrubber: React.FC = ({ const [hasLoaded, setHasLoaded] = useState(false); const spriteInfo = useSpriteInfo(hasLoaded ? vttPath : undefined); + const spriteSheetSize = useMemo(() => { + if (!spriteInfo) { + return { x: 0, y: 0 }; + } + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + + return { x: maxX, y: maxY }; + }, [spriteInfo]); + const sprite = useMemo(() => { if (!spriteInfo || activeIndex === undefined) { return undefined; @@ -69,17 +81,17 @@ export const PreviewScrubber: React.FC = ({ const clientRect = imageParent.getBoundingClientRect(); const scale = scaleToFit(sprite, clientRect); - const spriteSheet = new Image(); - spriteSheet.src = sprite.url; setStyle({ - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundPosition: `${-sprite.x * scale}px ${-sprite.y * scale}px`, backgroundImage: `url(${sprite.url})`, - width: `${sprite.w}px`, - height: `${sprite.h}px`, - transform: `scale(${scale})`, + backgroundSize: `${spriteSheetSize.x * scale}px ${ + spriteSheetSize.y * scale + }px`, + width: `${sprite.w * scale}px`, + height: `${sprite.h * scale}px`, }); - }, [sprite]); + }, [sprite, spriteSheetSize]); const currentTime = useMemo(() => { if (!sprite) return undefined; From fbf91b25262dcbb39a8cdcc566ae39d5c8d757e6 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:01:31 -0800 Subject: [PATCH 035/152] New: Add From Clipboard to Set Image (#6637) * add from clipboard to UI * only trigger when input not focused --- ui/v2.5/src/components/Shared/ImageInput.tsx | 37 ++++++++- ui/v2.5/src/locales/en-GB.json | 4 + ui/v2.5/src/utils/image.tsx | 85 ++++++++++++-------- 3 files changed, 93 insertions(+), 33 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index 7675da41f..57b8f06f8 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -10,8 +10,10 @@ import { import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; -import { faFile, faLink } from "@fortawesome/free-solid-svg-icons"; +import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; +import ImageUtils from "src/utils/image"; +import { useToast } from "src/hooks/Toast"; interface IImageInput { isEditing: boolean; @@ -39,6 +41,7 @@ export const ImageInput: React.FC = PatchComponent( const [isShowDialog, setIsShowDialog] = useState(false); const [url, setURL] = useState(""); const intl = useIntl(); + const Toast = useToast(); if (!isEditing) return
; @@ -58,6 +61,28 @@ export const ImageInput: React.FC = PatchComponent( ); } + async function onPasteClipboard() { + try { + const data = await ImageUtils.readClipboardImage(); + if (data && onImageURL) { + onImageURL(data); + Toast.success( + intl.formatMessage({ id: "toast.clipboard_image_pasted" }) + ); + } else { + Toast.error(intl.formatMessage({ id: "toast.clipboard_no_image" })); + } + } catch (e) { + if (e instanceof DOMException && e.name === "NotAllowedError") { + Toast.error( + intl.formatMessage({ id: "toast.clipboard_access_denied" }) + ); + } else { + Toast.error(e); + } + } + } + function showDialog() { setURL(""); setIsShowDialog(true); @@ -127,6 +152,16 @@ export const ImageInput: React.FC = PatchComponent( {intl.formatMessage({ id: "actions.from_url" })}
+ {window.isSecureContext && ( +
+ +
+ )} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 56b11f3f9..8a0ad96da 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -56,6 +56,7 @@ "export_all": "Export all…", "find": "Find", "finish": "Finish", + "from_clipboard": "From clipboard", "from_file": "From file…", "from_url": "From URL…", "full_export": "Full export", @@ -1636,6 +1637,9 @@ "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "Added generation job to queue", + "clipboard_access_denied": "Clipboard access denied. Check your browser permissions", + "clipboard_image_pasted": "Image pasted from clipboard", + "clipboard_no_image": "No image found in clipboard", "created_entity": "Created {entity}", "default_filter_set": "Default filter set", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", diff --git a/ui/v2.5/src/utils/image.tsx b/ui/v2.5/src/utils/image.tsx index b31387e83..73b833f80 100644 --- a/ui/v2.5/src/utils/image.tsx +++ b/ui/v2.5/src/utils/image.tsx @@ -1,25 +1,18 @@ import React, { useCallback, useEffect } from "react"; +const blobToDataURL = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const readImage = (file: File, onLoadEnd: (imageData: string) => void) => { - const reader: FileReader = new FileReader(); - reader.onloadend = () => { - // only proceed if no error encountered - if (!reader.error) { - onLoadEnd(reader.result as string); - } - }; - reader.readAsDataURL(file); -}; - -const pasteImage = ( - event: ClipboardEvent, - onLoadEnd: (imageData: string) => void -) => { - const files = event?.clipboardData?.files; - if (!files?.length) return; - - const file = files[0]; - readImage(file, onLoadEnd); + // only proceed if no error encountered + blobToDataURL(file) + .then(onLoadEnd) + .catch(() => {}); }; const onImageChange = ( @@ -30,6 +23,46 @@ const onImageChange = ( if (file) readImage(file, onLoadEnd); }; +const imageToDataURL = async (url: string) => { + const response = await fetch(url); + const blob = await response.blob(); + return blobToDataURL(blob); +}; + +// uses event.clipboardData which works in all contexts including insecure HTTP +const pasteImage = ( + event: ClipboardEvent, + onLoadEnd: (imageData: string) => void +) => { + const files = event?.clipboardData?.files; + if (!files?.length) return; + + if (document.activeElement instanceof HTMLInputElement) { + // don't interfere with pasting text into inputs + return; + } + + const file = Array.from(files).find((f) => f.type.startsWith("image/")); + if (file) readImage(file, onLoadEnd); +}; + +// uses Clipboard API which requires secure context (HTTPS or localhost) +const readClipboardImage = async (): Promise => { + if (!window.isSecureContext) { + return null; + } + + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((t) => t.startsWith("image/")); + if (imageType) { + const blob = await item.getType(imageType); + return blobToDataURL(blob); + } + } + return null; +}; + const usePasteImage = ( onLoadEnd: (imageData: string) => void, isActive: boolean = true @@ -53,23 +86,11 @@ const usePasteImage = ( return false; }; -const imageToDataURL = async (url: string) => { - const response = await fetch(url); - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - resolve(reader.result as string); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -}; - const ImageUtils = { onImageChange, usePasteImage, imageToDataURL, + readClipboardImage, }; export default ImageUtils; From 69e781b0ee3ed210787c2f2f9a1cf4424d3e2da3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:58:51 +1100 Subject: [PATCH 036/152] Use ffmpeg as a general fallback when generating phash (#6641) --- pkg/hash/imagephash/phash.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/hash/imagephash/phash.go b/pkg/hash/imagephash/phash.go index 73e8e3667..0af5adec9 100644 --- a/pkg/hash/imagephash/phash.go +++ b/pkg/hash/imagephash/phash.go @@ -3,10 +3,9 @@ package imagephash import ( "bytes" "context" + "errors" "fmt" "image" - "path/filepath" - "strings" "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/ffmpeg" @@ -32,17 +31,9 @@ func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, err } // loadImage loads an image from disk and decodes it. -// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder. +// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first. func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) { - ext := strings.ToLower(filepath.Ext(imageFile.Path)) - if ext == ".avif" { - // AVIF in zip files is not supported - ffmpeg cannot read files inside zips - if imageFile.Base().ZipFileID != nil { - return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation") - } - return loadImageFFmpeg(encoder, imageFile.Path) - } - + // try to load with Go's built-in decoders first for better performance reader, err := imageFile.Open(&file.OsFS{}) if err != nil { return nil, err @@ -55,6 +46,15 @@ func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image } img, _, err := image.Decode(buf) + if errors.Is(err, image.ErrFormat) { + // try ffmpeg as a fallback for unsupported formats + // ffmpeg cannot read files inside zips + if imageFile.Base().ZipFileID != nil { + return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files") + } + return loadImageFFmpeg(encoder, imageFile.Path) + } + if err != nil { return nil, fmt.Errorf("decoding image: %w", err) } From 697c66ae627261376a01734a5b4362887904cad0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:59:13 +1100 Subject: [PATCH 037/152] Allow stash path to non-existing directory (#6644) --- internal/api/resolver_mutation_configure.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 718d24998..3df1c9114 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "path/filepath" "regexp" "strconv" @@ -85,6 +86,8 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) { func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { c := config.GetInstance() + // #4709 - allow stash paths even if they do not exist, so that users may configure stash + // for disconnected drives or network storage. existingPaths := c.GetStashPaths() if input.Stashes != nil { for _, s := range input.Stashes { @@ -97,8 +100,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen } } if isNew { + s.Path = filepath.Clean(s.Path) + + // if it exists, it must be directory exists, err := fsutil.DirExists(s.Path) - if !exists { + // allow it to not exist but if it does exist it must be a directory + if !exists && !errors.Is(err, fs.ErrNotExist) { return makeConfigGeneralResult(), err } } From 717f968a2c544a3f0c0f0be0b30e45cd0da58f10 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:02:13 +1100 Subject: [PATCH 038/152] Add folder criteria to scenes, images and galleries and sidebars (#6636) * Add useDebouncedState hook * Add basename to folder sort whitelist * Add parent_folder criterion to gallery * Add selection on enter if single result --- graphql/schema/types/filters.graphql | 2 + pkg/models/gallery.go | 2 + pkg/sqlite/folder.go | 1 + pkg/sqlite/gallery_filter.go | 60 ++ ui/v2.5/graphql/data/file.graphql | 15 + ui/v2.5/graphql/queries/folder.graphql | 24 + .../src/components/Galleries/GalleryList.tsx | 9 + ui/v2.5/src/components/Images/ImageList.tsx | 7 + .../src/components/List/CriterionEditor.tsx | 21 +- .../components/List/Filters/FolderFilter.tsx | 612 ++++++++++++++++++ .../List/Filters/LabeledIdFilter.tsx | 22 +- .../List/Filters/SelectableFilter.tsx | 95 ++- .../List/Filters/SidebarListFilter.tsx | 26 +- ui/v2.5/src/components/List/styles.scss | 43 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 7 + .../src/components/Shared/ClearableInput.tsx | 5 + .../src/components/Shared/CollapseButton.tsx | 14 +- ui/v2.5/src/components/Shared/Sidebar.tsx | 18 +- ui/v2.5/src/core/StashService.ts | 15 + ui/v2.5/src/hooks/debounce.ts | 29 +- ui/v2.5/src/locales/en-GB.json | 5 + .../models/list-filter/criteria/criterion.ts | 1 + .../src/models/list-filter/criteria/folder.ts | 52 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/types.ts | 4 +- 27 files changed, 1025 insertions(+), 70 deletions(-) create mode 100644 ui/v2.5/graphql/queries/folder.graphql create mode 100644 ui/v2.5/src/components/List/Filters/FolderFilter.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/folder.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 6eda473b4..323fb8741 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -603,6 +603,8 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + "Filter by parent folder of the zip or folder the gallery is in" + parent_folder: HierarchicalMultiCriterionInput custom_fields: [CustomFieldCriterionInput!] } diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 8f335020a..3bf70b754 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -11,6 +11,8 @@ type GalleryFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by parent folder + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` // Filter by zip file count FileCount *IntCriterionInput `json:"file_count"` // Filter to only include galleries missing this property diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index fdeb00913..549b40d31 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -614,6 +614,7 @@ var folderSortOptions = sortOptions{ "created_at", "id", "path", + "basename", "random", "updated_at", } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index 069bb1015..28f3b8fac 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -84,6 +84,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { }), qb.pathCriterionHandler(filter.Path), + qb.parentFolderCriterionHandler(filter.ParentFolder), qb.fileCountCriterionHandler(filter.FileCount), intCriterionHandler(filter.Rating100, "galleries.rating", nil), qb.urlsCriterionHandler(filter.URL), @@ -278,6 +279,65 @@ func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionIn } } +func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + galleryRepository.addFoldersTable(f) + f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") + + criterion := *folder + switch criterion.Modifier { + case models.CriterionModifierEquals: + criterion.Modifier = models.CriterionModifierIncludes + case models.CriterionModifierNotEquals: + criterion.Modifier = models.CriterionModifierExcludes + } + + // only allow includes or excludes filters + if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes { + f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier)) + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludes + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + // combine clauses with OR to handle zip file or folder + c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) + f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) + } + } +} + func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 52a4c50f8..7386adb81 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -1,5 +1,6 @@ fragment FolderData on Folder { id + basename path } @@ -86,3 +87,17 @@ fragment VisualFileData on VisualFile { } } } + +fragment SelectFolderData on Folder { + id + path + basename +} + +fragment RecursiveFolderData on Folder { + ...SelectFolderData + + parent_folders { + ...SelectFolderData + } +} diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql new file mode 100644 index 000000000..a42f5eb84 --- /dev/null +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -0,0 +1,24 @@ +query FindRootFoldersForSelect { + findFolders( + filter: { per_page: -1, sort: "path", direction: ASC } + folder_filter: { parent_folder: { modifier: IS_NULL } } + ) { + count + folders { + ...SelectFolderData + } + } +} + +query FindFoldersForQuery( + $filter: FindFilterType + $folder_filter: FolderFilterType + $ids: [ID!] +) { + findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) { + count + folders { + ...RecursiveFolderData + } + } +} diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index b31b597cc..abb2bdda8 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -51,6 +51,8 @@ import { import { FilterTags } from "../List/FilterTags"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; +import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder"; const GalleryList: React.FC<{ galleries: GQL.SlimGalleryDataFragment[]; @@ -165,6 +167,13 @@ const SidebarContent: React.FC<{ filterHook={filterHook} /> + } + criterionOption={ParentFolderCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="parent_folder" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 50956b497..35c367a8a 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -66,6 +66,7 @@ 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"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -430,6 +431,12 @@ const SidebarContent: React.FC<{ filterHook={filterHook} /> + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 8795296fa..8a72d6e43 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -52,6 +52,11 @@ import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter"; +import { FolderFilter } from "./Filters/FolderFilter"; +import { + FolderCriterion, + ParentFolderCriterion, +} from "src/models/list-filter/criteria/folder"; interface IGenericCriterionEditor { criterion: ModifierCriterion; @@ -68,7 +73,9 @@ const GenericCriterionEditor: React.FC = ({ if ( criterion instanceof PerformersCriterion || criterion instanceof StudiosCriterion || - criterion instanceof TagsCriterion + criterion instanceof TagsCriterion || + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion ) { return false; } @@ -163,6 +170,18 @@ const GenericCriterionEditor: React.FC = ({ ); } + if ( + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion + ) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( void; + onSelect: (folder: IFolder, exclude?: boolean) => void; +}> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => { + return ( + <> +
  • + onSelect(folder)} + onKeyDown={keyboardClickHandler(() => onSelect(folder))} + tabIndex={0} + > + + + toggleExpanded(folder)} + collapsedIcon={faChevronRight} + notCollapsedIcon={faChevronDown} + /> + + {folder.basename} + + {canExclude && ( + + )} + +
  • + {folder.expanded && + folder.children?.map((child) => ( + + ))} + + ); +}; + +function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === object.id) { + return { ...f, expanded: !f.expanded }; + } + + if (f.children) { + return { + ...f, + children: f.children.map(toggleExpandedFn(object)), + }; + } + + return f; + }; +} + +function replaceFolder(folder: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === folder.id) { + return folder; + } + + if (f.children) { + return { + ...f, + children: f.children.map(replaceFolder(folder)), + }; + } + + return f; + }; +} + +function useFolderMap(query: string, skip?: boolean) { + const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ + skip, + }); + + const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ + skip: !query, + variables: { + filter: { q: query, per_page: 200 }, + }, + }); + + const rootFolders: IFolder[] = useMemo(() => { + const ret = rootFoldersResult?.findFolders.folders ?? []; + return ret.map((f) => ({ ...f, expanded: false, children: undefined })); + }, [rootFoldersResult]); + + const queryFolders: IFolder[] = useMemo(() => { + // construct the folder list from the query result + const ret: IFolder[] = []; + + (queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => { + if (!folder.parent_folders.length) { + // no parents, just add it if not present + if (!ret.find((f) => f.id === folder.id)) { + ret.push({ ...folder, expanded: true, children: [] }); + } + return; + } + + // expand the parent folders + let currentParent: IFolder | undefined; + for (let i = folder.parent_folders.length - 1; i >= 0; i--) { + const thisFolder = folder.parent_folders[i]; + let existing: IFolder | undefined; + + if (i === folder.parent_folders.length - 1) { + // last parent, add the folder as root + existing = ret.find((f) => f.id === thisFolder.id); + if (!existing) { + existing = { + ...folder.parent_folders[i], + expanded: true, + children: [], + }; + ret.push(existing); + } + currentParent = existing; + continue; + } + + // find folder in current parent's children + // currentParent is guaranteed to be defined here + existing = currentParent!.children?.find((f) => f.id === thisFolder.id); + if (!existing) { + // add to current parent's children + existing = { + ...thisFolder, + expanded: true, + children: [], + }; + currentParent!.children!.push(existing); + } + currentParent = existing; + } + + if (!currentParent) { + return; + } + + if (!currentParent.children) { + currentParent.children = []; + } + + // currentParent is now the immediate parent folder + currentParent!.children!.push({ + ...folder, + expanded: false, + children: undefined, + }); + }); + return ret; + }, [queryFoldersResult]); + + const [folderMap, setFolderMap] = React.useState([]); + + useEffect(() => { + if (!query) { + setFolderMap(rootFolders); + } else { + setFolderMap(queryFolders); + } + }, [query, rootFolders, queryFolders]); + + async function onToggleExpanded(folder: IFolder) { + setFolderMap(folderMap.map(toggleExpandedFn(folder))); + + // query children folders if not already loaded + if (folder.children === undefined) { + const subFolderResult = await queryFindSubFolders(folder.id); + setFolderMap((current) => + current.map( + replaceFolder({ + ...folder, + expanded: true, + children: subFolderResult.data.findFolders.folders.map((f) => ({ + ...f, + expanded: false, + })), + }) + ) + ); + } + } + + return { folderMap, onToggleExpanded }; +} + +function getMatchingFolders(folders: IFolder[], query: string): IFolder[] { + let matches: IFolder[] = []; + + const queryLower = query.toLowerCase(); + + folders.forEach((folder) => { + if ( + folder.basename.toLowerCase().includes(queryLower) || + folder.path.toLowerCase() === queryLower + ) { + matches.push(folder); + } + + if (folder.children) { + matches = matches.concat(getMatchingFolders(folder.children, query)); + } + }); + + return matches; +} + +export const FolderSelector: React.FC<{ + onSelect: (folder: IFolder, exclude?: boolean) => void; + canExclude?: boolean; + preListContent?: React.ReactNode; + folderMap: IFolder[]; + onToggleExpanded: (folder: IFolder) => void; +}> = ({ + onSelect, + preListContent, + canExclude = false, + folderMap, + onToggleExpanded, +}) => { + return ( +
      + {preListContent} + {folderMap.map((folder) => ( + onSelect(f, exclude)} + toggleExpanded={onToggleExpanded} + canExclude={canExclude} + /> + ))} +
    + ); +}; + +interface IInputFilterProps { + criterion: FolderCriterion; + setCriterion: (c: FolderCriterion) => void; +} + +export const FolderFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + const intl = useIntl(); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + const { folderMap, onToggleExpanded } = useFolderMap(query); + + const messages = defineMessages({ + sub_folder_depth: { + id: "sub_folder_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function criterionOptionTypeToIncludeID(): string { + return "include-sub-folders"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = "include_sub_folders"; + + return { + id: optionType, + }; + } + + function onDepthChanged(depth: number) { + // this could be ParentFolderCriterion, but the types are the same + const newValue = criterion.clone() as FolderCriterion; + newValue.value.depth = depth; + setCriterion(newValue); + } + + function onSelect(folder: IFolder, exclude: boolean = false) { + // toggle selection + const newValue = criterion.clone() as FolderCriterion; + + if (!exclude) { + if (newValue.value.items.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.items.push({ id: folder.id, label: folder.path }); + } else { + if (newValue.value.excluded.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.excluded.push({ id: folder.id, label: folder.path }); + } + + setCriterion(newValue); + } + + const onUnselect = useCallback( + (i: Option, excluded?: boolean) => { + const newValue = criterion.clone() as FolderCriterion; + + if (!excluded) { + newValue.value.items = newValue.value.items.filter( + (item) => item.id !== i.id + ); + } else { + newValue.value.excluded = newValue.value.excluded.filter( + (item) => item.id !== i.id + ); + } + setCriterion(newValue); + }, + [criterion, setCriterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const selectedList = useMemo(() => { + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ; + }, [criterion, onUnselect]); + + const excludedList = useMemo(() => { + const selected: Option[] = + criterion.value?.excluded.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ( + onUnselect(i, true)} + /> + ); + }, [criterion, onUnselect]); + + return ( +
    + + + + {selectedList} + {excludedList} + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + +
    + ); +}; + +export const SidebarFolderFilter: React.FC< + ISidebarSectionProps & { + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + criterionOption?: ModifierCriterionOption; + } +> = (props) => { + const intl = useIntl(); + const [skip, setSkip] = useState(true); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + function onOpen() { + setSkip(false); + props.onOpen?.(); + } + + const { folderMap, onToggleExpanded } = useFolderMap(query, skip); + + const option = props.criterionOption ?? FolderCriterionOption; + const { filter, setFilter } = props; + + const criterion = useMemo(() => { + const ret = filter.criteria.find( + (c) => c.criterionOption.type === option.type + ); + if (ret) return ret as FolderCriterion; + + const newCriterion = filter.makeCriterion(option.type) as FolderCriterion; + return newCriterion; + }, [option.type, filter]); + + // if there are multiple values or excluded values, then we show none of the + // current values + const multipleSelected = + criterion.value.items.length > 1 || criterion.value.excluded.length > 0; + + function onSelect(folder: IFolder) { + const c = criterion.clone() as FolderCriterion; + c.value = { + items: [{ id: folder.id, label: folder.path }], + depth: 0, + excluded: [], + }; + + const newCriteria = props.filter.criteria.filter( + (cc) => cc.criterionOption.type !== option.type + ); + + if (c.isValid()) newCriteria.push(c); + + setFilter(props.filter.setCriteria(newCriteria)); + } + + function onSelectSubfolders() { + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: -1, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + } + + const onUnselect = useCallback( + (i: Option) => { + if (i.className === "modifier-object") { + // subfolders option + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: 0, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + return; + } + + setFilter(props.filter.removeCriterion(option.type)); + }, + [props.filter, setFilter, option.type, criterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const subDirsSelected = criterion.value?.depth === -1; + + const selectedList = useMemo(() => { + if (multipleSelected) { + return null; + } + + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + if (subDirsSelected) { + selected.push({ + id: "subfolders", + label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")", + className: "modifier-object", + }); + } + + return ; + }, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]); + + const modifierItem = criterion.value.items.length > 0 && + !multipleSelected && + !subDirsSelected && ( +
  • + + + + () + + +
  • + ); + + return ( + + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + onSelect(f)} + /> + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index e006d6b50..355a85d67 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -391,10 +391,17 @@ export function useCandidates(props: { const defaultModifier = getDefaultModifier(singleValue); const candidates = useMemo(() => { + return (results ?? []).map((r) => ({ + id: r.id, + label: r.label, + })); + }, [results]); + + const modifierCandidates = useMemo(() => { const hierarchicalCandidate = hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1; - const modifierCandidates: Option[] = getModifierCandidates({ + return getModifierCandidates({ modifier, defaultModifier, hasSelected: selected.length > 0, @@ -416,19 +423,11 @@ export function useCandidates(props: { canExclude: false, }; }); - - return modifierCandidates.concat( - (results ?? []).map((r) => ({ - id: r.id, - label: r.label, - })) - ); }, [ defaultModifier, intl, modifier, singleValue, - results, selected, excluded, criterion.value, @@ -436,7 +435,7 @@ export function useCandidates(props: { includeSubMessageID, ]); - return candidates; + return { candidates, modifierCandidates }; } export function useLabeledIdFilterState(props: { @@ -481,7 +480,7 @@ export function useLabeledIdFilterState(props: { includeSubMessageID, }); - const candidates = useCandidates({ + const { candidates, modifierCandidates } = useCandidates({ criterion, queryResults, selected, @@ -497,6 +496,7 @@ export function useLabeledIdFilterState(props: { return { candidates, + modifierCandidates, onSelect, onUnselect, selected, diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 9ea4333da..e599f3a87 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -19,7 +19,12 @@ import { ModifierCriterion, IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; -import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { + defineMessages, + FormattedMessage, + MessageDescriptor, + useIntl, +} from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; @@ -118,7 +123,9 @@ const UnselectedItem: React.FC<{ onKeyDown={(e) => e.stopPropagation()} className="minimal exclude-button" > - exclude + + + {excludeIcon} )} @@ -240,12 +247,19 @@ const SelectableFilter: React.FC = ({ onSetModifier(defaultModifier); } + function onEnter() { + if (objects.length === 1) { + onSelect(objects[0], false); + } + } + return (
    onQueryChange(v)} + onEnter={onEnter} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} />
      @@ -450,6 +464,42 @@ export const ObjectsFilter = < ); }; +export const DepthSelector: React.FC<{ + depth: number | undefined; + onDepthChanged: (depth: number) => void; + id: string; + label?: React.ReactNode; + placeholder?: string; + disabled?: boolean; +}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => { + return ( + + + onDepthChanged(depth !== 0 ? 0 : -1)} + disabled={disabled} + /> + + {depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={depth !== -1 ? depth : ""} + min="1" + /> + + )} + + ); +}; + interface IHierarchicalObjectsFilter extends IObjectsFilter {} @@ -497,38 +547,15 @@ export const HierarchicalObjectsFilter = < } return ( -
      - - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} - disabled={criterion.modifier === CriterionModifier.Equals} - /> - - - {criterion.value.depth !== 0 && ( - - - onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) - } - defaultValue={ - criterion.value && criterion.value.depth !== -1 - ? criterion.value.depth - : "" - } - min="1" - /> - - )} +
      + - +
      ); }; diff --git a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx index fe9b7987c..14e11e968 100644 --- a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx @@ -182,7 +182,8 @@ const QueryField: React.FC<{ focus: ReturnType; value: string; setValue: (query: string) => void; -}> = ({ focus, value, setValue }) => { + onEnter?: () => void; +}> = ({ focus, value, setValue, onEnter }) => { const intl = useIntl(); const [displayQuery, setDisplayQuery] = useState(value); @@ -206,6 +207,7 @@ const QueryField: React.FC<{ value={displayQuery} setValue={(v) => onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} /> ); }; @@ -214,6 +216,7 @@ interface IQueryableProps { inputFocus?: ReturnType; query?: string; setQuery?: (query: string) => void; + onEnter?: () => void; } export const CandidateList: React.FC< @@ -227,6 +230,7 @@ export const CandidateList: React.FC< inputFocus, query, setQuery, + onEnter, items, onSelect, canExclude, @@ -242,6 +246,7 @@ export const CandidateList: React.FC< focus={inputFocus} value={query} setValue={(v) => setQuery(v)} + onEnter={onEnter} /> )}
        @@ -265,6 +270,7 @@ export const SidebarListFilter: React.FC<{ selected: Option[]; excluded?: Option[]; candidates: Option[]; + modifierCandidates?: Option[]; singleValue?: boolean; onSelect: (item: Option, exclude: boolean) => void; onUnselect: (item: Option, exclude: boolean) => void; @@ -283,6 +289,7 @@ export const SidebarListFilter: React.FC<{ selected, excluded, candidates, + modifierCandidates, onSelect, onUnselect, canExclude, @@ -324,6 +331,20 @@ export const SidebarListFilter: React.FC<{ } } + function onEnter() { + if (candidates && candidates.length === 1) { + selectHook(candidates[0], false); + } + } + + const items = useMemo(() => { + if (!modifierCandidates) { + return candidates; + } + + return [...modifierCandidates, ...candidates]; + }, [candidates, modifierCandidates]); + return ( {preCandidates ?
        {preCandidates}
        : null} {postCandidates ?
        {postCandidates}
        : null}
        diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index ede004101..cccd73cb2 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -234,14 +234,14 @@ input[type="range"].zoom-slider { .saved-filter-item { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -507,7 +507,8 @@ input[type="range"].zoom-slider { } } -.selectable-filter ul { +.selectable-filter ul, +ul.selectable-list { list-style-type: none; margin-top: 0.5rem; max-height: 300px; @@ -533,14 +534,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -613,7 +614,8 @@ input[type="range"].zoom-slider { margin-bottom: 0.5rem; } -.sidebar-list-filter ul { +.sidebar-list-filter ul, +.folder-filter ul { list-style-type: none; margin-bottom: 0.25rem; max-height: 300px; @@ -639,14 +641,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -687,7 +689,7 @@ input[type="range"].zoom-slider { } &:hover { - background-color: inherit; + background-color: transparent; } &:hover .exclude-button-text, @@ -748,6 +750,29 @@ input[type="range"].zoom-slider { } } +.sidebar-folder-filter ul, +.folder-filter ul, +ul.selectable-list { + margin-top: 0.25rem; + + .btn.expand-collapse { + font-size: 0.8rem; + padding-left: 0; + padding-right: 0.25rem; + text-align: left; + } + + .empty .btn.expand-collapse { + visibility: hidden; + } + + .selected-object a .selected-object-label { + font-size: 0.8em; + overflow-wrap: break-word; + white-space: normal; + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 6570e39db..156258045 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -58,6 +58,7 @@ import useFocus from "src/utils/focus"; import { useZoomKeybinds } from "../List/ZoomSlider"; import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -305,6 +306,12 @@ const SidebarContent: React.FC<{ /> + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> } data-type={HasMarkersCriterionOption.type} diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx index 76c6db54a..56f17a7f9 100644 --- a/ui/v2.5/src/components/Shared/ClearableInput.tsx +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -10,6 +10,7 @@ interface IClearableInput { className?: string; value: string; setValue: (value: string) => void; + onEnter?: () => void; focus?: ReturnType; placeholder?: string; } @@ -18,6 +19,7 @@ export const ClearableInput: React.FC = ({ className, value, setValue, + onEnter, focus, placeholder, }) => { @@ -43,6 +45,9 @@ export const ClearableInput: React.FC = ({ if (e.key === "Escape") { queryRef.current?.blur(); } + if (e.key === "Enter" && onEnter) { + onEnter(); + } } return ( diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index 0d05f6e64..fe8330a9c 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -2,6 +2,7 @@ import { faChevronDown, faChevronRight, faChevronUp, + IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; @@ -55,14 +56,21 @@ export const CollapseButton: React.FC> = ( export const ExpandCollapseButton: React.FC<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; -}> = ({ collapsed, setCollapsed }) => { - const buttonIcon = collapsed ? faChevronDown : faChevronUp; + collapsedIcon?: IconDefinition; + notCollapsedIcon?: IconDefinition; +}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => { + const buttonIcon = collapsed + ? collapsedIcon ?? faChevronDown + : notCollapsedIcon ?? faChevronUp; return ( diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 10dbaaaba..51fddee33 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -97,15 +97,17 @@ interface IContext { export const SidebarStateContext = React.createContext(null); +export interface ISidebarSectionProps { + text: React.ReactNode; + className?: string; + outsideCollapse?: React.ReactNode; + onOpen?: () => void; + // used to store open/closed state in SidebarStateContext + sectionID?: string; +} + export const SidebarSection: React.FC< - PropsWithChildren<{ - text: React.ReactNode; - className?: string; - outsideCollapse?: React.ReactNode; - onOpen?: () => void; - // used to store open/closed state in SidebarStateContext - sectionID?: string; - }> + PropsWithChildren > = ({ className = "", text, diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 535beed65..ac23d59e0 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -515,6 +515,21 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); +export const queryFindSubFolders = (id: string) => + client.query({ + query: GQL.FindFoldersForQueryDocument, + variables: { + folder_filter: { + parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals }, + }, + filter: { + per_page: -1, + sort: "basename", + direction: GQL.SortDirectionEnum.Asc, + }, + }, + }); + /// Object Mutations // Increases/decreases the given field of the Stats query by diff diff --git a/ui/v2.5/src/hooks/debounce.ts b/ui/v2.5/src/hooks/debounce.ts index 9baf3d1d4..bc4f63ef1 100644 --- a/ui/v2.5/src/hooks/debounce.ts +++ b/ui/v2.5/src/hooks/debounce.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; export function useDebounce any>( fn: T, @@ -21,3 +21,30 @@ export function useDebounce any>( [wait, options?.leading, options?.trailing, options?.maxWait] ); } + +export function useDebouncedState( + initialValue: T, + setValue: (v: T) => void, + wait?: number +): [T, (v: T) => void, (v: T) => void] { + const [displayedState, setDisplayedState] = useState(initialValue); + + const debouncedSetValue = useDebounce(setValue, wait); + const onChange = useCallback( + (input: T) => { + setDisplayedState(input); + debouncedSetValue(input); + }, + [debouncedSetValue, setDisplayedState] + ); + + const setInstant = useCallback( + (v: T) => { + setDisplayedState(v); + setValue(v); + }, + [setValue] + ); + + return [displayedState, onChange, setInstant]; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8a0ad96da..7b4091f8b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -52,6 +52,7 @@ "edit_entity": "Edit {entityType}", "enable": "Enable", "encoding_image": "Encoding image…", + "exclude_lowercase": "exclude", "export": "Export", "export_all": "Export all…", "find": "Find", @@ -1225,6 +1226,7 @@ "image_index": "Image #", "images": "Images", "include_parent_tags": "Include parent tags", + "include_sub_folders": "Include sub-folders", "include_sub_group_content": "Include sub-group content", "include_sub_groups": "Include sub-groups", "include_sub_studio_content": "Include sub-studio content", @@ -1327,6 +1329,7 @@ "next": "Next", "previous": "Previous" }, + "parent_folder": "Parent Folder", "parent_of": "Parent of {children}", "parent_studio": "Parent Studio", "parent_studios": "Parent Studios", @@ -1578,6 +1581,8 @@ }, "studio_tags": "Studio Tags", "studios": "Studios", + "sub_folder_depth": "Sub folder depth (empty for all)", + "sub_folders": "Sub folders", "sub_group": "Sub-Group", "sub_group_count": "Sub-Group Count", "sub_group_of": "Sub-group of {parent}", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 007ee6508..37f04e6dc 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -251,6 +251,7 @@ export type InputType = | "scene_tags" | "groups" | "galleries" + | "folders" | undefined; type MakeCriterionFn = ( diff --git a/ui/v2.5/src/models/list-filter/criteria/folder.ts b/ui/v2.5/src/models/list-filter/criteria/folder.ts new file mode 100644 index 000000000..2e288a5f1 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/folder.ts @@ -0,0 +1,52 @@ +import { CriterionModifier } from "src/core/generated-graphql"; +import { + ModifierCriterionOption, + IHierarchicalLabeledIdCriterion, +} from "./criterion"; + +const modifierOptions = [CriterionModifier.Includes]; + +const defaultModifier = CriterionModifier.Includes; +const inputType = "folders"; + +export const FolderCriterionOption = new ModifierCriterionOption({ + messageID: "folder", + type: "folder", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new FolderCriterion(), +}); + +// for galleries, we should use parent folder to distinguish between gallery folder +// and parent folder of the gallery folder +export const ParentFolderCriterionOption = new ModifierCriterionOption({ + messageID: "parent_folder", + type: "parent_folder", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new ParentFolderCriterion(), +}); + +export class FolderCriterion extends IHierarchicalLabeledIdCriterion { + constructor() { + super(FolderCriterionOption); + } + + public applyToCriterionInput(input: Record) { + input.files_filter = { + parent_folder: this.toCriterionInput(), + }; + } +} + +export class ParentFolderCriterion extends IHierarchicalLabeledIdCriterion { + constructor() { + super(ParentFolderCriterionOption); + } + + public applyToCriterionInput(input: Record) { + input.parent_folder = this.toCriterionInput(); + } +} diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index e8549701f..ed0b2c155 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -22,6 +22,7 @@ import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { ParentFolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "path"; @@ -53,6 +54,7 @@ const criterionOptions = [ createStringCriterionOption("details"), createStringCriterionOption("photographer"), PathCriterionOption, + ParentFolderCriterionOption, createStringCriterionOption("checksum", "media_info.md5"), RatingCriterionOption, OrganizedCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index aa6ad81ca..82b382677 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -23,6 +23,7 @@ import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { PhashCriterionOption } from "./criteria/phash"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { FolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "path"; @@ -54,6 +55,7 @@ const criterionOptions = [ createMandatoryStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, PathCriterionOption, + FolderCriterionOption, GalleriesCriterionOption, OrganizedCriterionOption, createMandatoryNumberCriterionOption("o_counter", "o_count", { diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index c0e4a75a1..ef7c62802 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -36,6 +36,7 @@ import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { FolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "date"; const sortByOptions = [ @@ -96,6 +97,7 @@ const criterionOptions = [ createStringCriterionOption("title"), createStringCriterionOption("code", "scene_code"), PathCriterionOption, + FolderCriterionOption, createStringCriterionOption("details"), createStringCriterionOption("director"), createMandatoryStringCriterionOption("oshash", "media_info.oshash"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 7fe334c4c..d5ae684fc 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -223,4 +223,6 @@ export type CriterionType = | "disambiguation" | "has_chapters" | "sort_name" - | "custom_fields"; + | "custom_fields" + | "folder" + | "parent_folder"; From 74a8f2e5d59ad70f2e7cd676fdf02537f03d6457 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:27:25 +1100 Subject: [PATCH 039/152] Disable links on wall items when selecting (#6649) --- ui/v2.5/src/components/Galleries/GalleryWallCard.tsx | 8 +++++++- ui/v2.5/src/components/Scenes/SceneWallPanel.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c1501bd9d..c79000783 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -132,7 +132,13 @@ const GalleryWallCard: React.FC = ({
        e.stopPropagation()} + onClick={(e) => { + if (selecting) { + e.preventDefault(); + handleCardClick(e); + } + e.stopPropagation(); + }} > {title && (
        - e.stopPropagation()}> + { + if (props.selecting) { + e.preventDefault(); + handleClick(e); + } + e.stopPropagation(); + }} + > {title && ( Date: Mon, 9 Mar 2026 17:01:46 -0400 Subject: [PATCH 040/152] Use StashIDPill in the performer modal dialog (#6655) Currently, this dialog just shows a text "Stash-Box Source". This change instead re-uses the StashIDPill, with the main advantage that you can immediately tell which stash box is being used. --- ui/v2.5/src/components/Tagger/PerformerModal.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index ac9444c5b..9b2434165 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -15,10 +15,10 @@ import { faArrowLeft, faArrowRight, faCheck, - faExternalLinkAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; +import { StashIDPill } from "../Shared/StashID"; interface IPerformerModalProps { performer: GQL.ScrapedScenePerformerDataFragment; @@ -208,15 +208,13 @@ const PerformerModal: React.FC = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - if (!base) return; + if (!base || !performer.remote_site_id) return; return ( -
        - - - - -
        + ); } From ae5d065da1980305b2b56a31fb55d5159f414df5 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:50:57 -0700 Subject: [PATCH 041/152] Fix infinite re-render loop in gallery image list (#6651) --- .../GalleryDetails/GalleryAddPanel.tsx | 63 ++++++++++--------- .../GalleryDetails/GalleryImagesPanel.tsx | 63 ++++++++++--------- ui/v2.5/src/components/Images/ImageList.tsx | 4 +- ui/v2.5/src/components/List/util.ts | 10 +-- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 6fbb12f15..e0c115f34 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } 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"; @@ -24,40 +24,43 @@ export const GalleryAddPanel: React.FC = PatchComponent( const Toast = useToast(); const intl = useIntl(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - galleryCriterion.modifier === GQL.CriterionModifier.Excludes - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + galleryCriterion.modifier === GQL.CriterionModifier.Excludes ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function addImages( result: GQL.FindImagesQueryResult, diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index 174e507a8..c555116b5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } 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"; @@ -32,40 +32,43 @@ export const GalleryImagesPanel: React.FC = const intl = useIntl(); const Toast = useToast(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id!, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id!, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || - galleryCriterion.modifier === GQL.CriterionModifier.Includes) - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || + galleryCriterion.modifier === GQL.CriterionModifier.Includes) ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function setCover( result: GQL.FindImagesQueryResult, diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 35c367a8a..00b23b0aa 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -751,7 +751,7 @@ export const FilteredImageList = PatchComponent( currentPage={filter.currentPage} itemsPerPage={filter.itemsPerPage} totalItems={totalCount} - onChangePage={(page) => setFilter(filter.changePage(page))} + onChangePage={setPage} /> setFilter(filter.changePage(page))} + onChangePage={setPage} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 89c32222f..da52ea765 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { useHistory, useLocation } from "react-router-dom"; @@ -489,20 +489,20 @@ export function useCachedQueryResult( result: T ) { const [cachedResult, setCachedResult] = useState(result); - const [lastFilter, setLastFilter] = useState(filter); + const lastFilterRef = useRef(filter); // if we are only changing the page or sort, don't update the result count useEffect(() => { if (!result.loading) { setCachedResult(result); } else { - if (totalCountImpacted(lastFilter, filter)) { + if (totalCountImpacted(lastFilterRef.current, filter)) { setCachedResult(result); } } - setLastFilter(filter); - }, [filter, result, lastFilter]); + lastFilterRef.current = filter; + }, [filter, result]); return cachedResult; } From 69a49c9ab8b36de520ed68d92759c706c2cf9277 Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:12:17 -0400 Subject: [PATCH 042/152] Show the stash box for each stash ID in the scene merge dialog (#6656) * Show the stash box for each stash ID in the scene merge dialog Currently, this dialog only shows the ID but not the stash box it corresponds to. This is not very useful because the ID does not mean anything to a user. This renders the ID as "Stashdb | 1234...", mimicing the StashIDPill. * Use StashIDPill instead --- .../src/components/Scenes/SceneMergeDialog.tsx | 15 +++++++++++++-- ui/v2.5/src/components/Shared/styles.scss | 6 +++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 89d445002..c38b27f07 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; -import { StringListSelect, GallerySelect } from "../Shared/Select"; +import { GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; @@ -41,13 +41,24 @@ import { ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; +import { StashIDPill } from "src/components/Shared/StashID"; interface IStashIDsField { values: GQL.StashId[]; } const StashIDsField: React.FC = ({ values }) => { - return v.stash_id)} />; + if (!values.length) return null; + + return ( +
          + {values.map((v) => ( +
        • + +
        • + ))} +
        + ); }; type MergeOptions = { diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f2881fc55..61226df49 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -666,10 +666,11 @@ div.react-datepicker { } .stash-id-pill { - display: inline-block; + display: inline-flex; font-size: 90%; font-weight: 700; line-height: 1; + max-width: 100%; padding-bottom: 0.25em; padding-top: 0.25em; text-align: center; @@ -685,12 +686,15 @@ div.react-datepicker { span { background-color: $primary; border-radius: 0.25rem 0 0 0.25rem; + flex-shrink: 0; min-width: 5em; } a { background-color: $secondary; border-radius: 0 0.25rem 0.25rem 0; + overflow: hidden; + text-overflow: ellipsis; } } From 490fa3ea14fb168386f0c08c672a30908c149f0c Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:53:20 -0400 Subject: [PATCH 043/152] Show scene resolution and duration in tagger (#6663) * Show scene resolution and duration in tagger A scene's duration and resolution is often useful to ensure you have found the right scene. This PR adds the same resolution/duration overlay from the grid view to the tagger view. --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 62 ++++++++++--------- .../components/Tagger/scenes/TaggerScene.tsx | 6 +- ui/v2.5/src/components/Tagger/styles.scss | 5 ++ 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b7c263168..e840dcbac 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -352,6 +352,37 @@ const SceneCardOverlays = PatchComponent( } ); +interface ISceneSpecsOverlay { + scene: GQL.SlimSceneDataFragment; +} + +export const SceneSpecsOverlay: React.FC = ({ scene }) => { + if (!scene.files.length) return null; + let file = scene.files[0]; + return ( +
        + + + + {file.width && file.height ? ( + + {" "} + {TextUtils.resolution(file.width, file.height)} + + ) : ( + "" + )} + {(file.duration ?? 0) >= 1 ? ( + + {TextUtils.secondsToTimestamp(file.duration)} + + ) : ( + "" + )} +
        + ); +}; + const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { @@ -364,35 +395,6 @@ const SceneCardImage = PatchComponent( [props.scene] ); - function maybeRenderSceneSpecsOverlay() { - return ( -
        - {file?.size !== undefined ? ( - - - - ) : ( - "" - )} - {file?.width && file?.height ? ( - - {" "} - {TextUtils.resolution(file?.width, file?.height)} - - ) : ( - "" - )} - {(file?.duration ?? 0) >= 1 ? ( - - {TextUtils.secondsToTimestamp(file?.duration ?? 0)} - - ) : ( - "" - )} -
        - ); - } - function maybeRenderInteractiveSpeedOverlay() { return (
        @@ -432,7 +434,7 @@ const SceneCardImage = PatchComponent( disabled={props.selecting} /> - {maybeRenderSceneSpecsOverlay()} + {maybeRenderInteractiveSpeedOverlay()} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 5446257e5..5ad895fc2 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -11,7 +11,10 @@ import { StashIDPill } from "src/components/Shared/StashID"; import { PerformerLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; -import { ScenePreview } from "src/components/Scenes/SceneCard"; +import { + ScenePreview, + SceneSpecsOverlay, +} from "src/components/Scenes/SceneCard"; import { TaggerStateContext } from "../context"; import { faChevronDown, @@ -271,6 +274,7 @@ export const TaggerScene: React.FC> = ({ vttPath={scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} /> + {maybeRenderSpriteIcon()}
        diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 8861d0043..5f6ece37d 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -8,6 +8,11 @@ .scene-card { position: relative; + + .scene-specs-overlay { + bottom: 5px; + right: 5px; + } } .scene-card-preview { From 300e7edb755193bba61d38bac6547648bab4b749 Mon Sep 17 00:00:00 2001 From: hyper440 <111574945+hyper440@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:07:46 +0300 Subject: [PATCH 044/152] fix: support string-based fingerprints in hashes filter (#6654) * fix: support string-based fingerprints in hashes filter * Fix tests and add phash test File fingerprints weren't using correct types. Filter test wasn't using correct types. Add phash to general files. --------- Co-authored-by: hyper440 Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/file_filter.go | 30 ++++++++++++++++--------- pkg/sqlite/file_filter_test.go | 41 +++++++++++++++++++++++++++++++++- pkg/sqlite/file_test.go | 6 ++--- pkg/sqlite/setup_test.go | 12 ++++++++-- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 157efb1d8..29946a8ce 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -238,22 +238,32 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint t := fmt.Sprintf("file_fingerprints_%d", i) f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type) - value, _ := utils.StringToPhash(hash.Value) distance := 0 if hash.Distance != nil { distance = *hash.Distance } - if distance > 0 { - // needed to avoid a type mismatch - f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) - f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + // Only phash supports distance matching and is stored as integer + if hash.Type == models.FingerprintTypePhash { + value, err := utils.StringToPhash(hash.Value) + if err != nil { + f.setError(fmt.Errorf("invalid phash value: %w", err)) + return + } + if distance > 0 { + // needed to avoid a type mismatch + f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) + f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + } else { + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: models.CriterionModifierEquals, + }, t+".fingerprint", nil)(ctx, f) + } } else { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: models.CriterionModifierEquals, - }, t+".fingerprint", nil)(ctx, f) + // All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings + // Use exact match for string-based fingerprints + f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value) } } } diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 50eed0129..648e502f7 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -81,7 +82,45 @@ func TestFileQuery(t *testing.T) { includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, - // TODO - add more tests for other file filters + { + name: "hashes md5", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeMD5, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes oshash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeOshash, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes phash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypePhash, + Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)), + }, + }, + }, + includeIdxs: []int{fileIdxStartImageFiles}, + excludeIdxs: []int{fileIdxStartVideoFiles}, + }, } for _, tt := range tests { diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index 8422390c0..55c41f4f7 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -572,7 +572,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by MD5", models.Fingerprint{ - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -581,7 +581,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by OSHASH", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -590,7 +590,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "non-existing", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: "foo", }, nil, diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index d8baae3b8..db59ff570 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -865,16 +865,24 @@ func getFileModTime(index int) time.Time { return getFolderModTime(index) } +func getFilePhash(index int) int64 { + return int64(index * 567) +} + func getFileFingerprints(index int) []models.Fingerprint { return []models.Fingerprint{ { - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", index, "md5"), }, { - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", index, "oshash"), }, + { + Type: models.FingerprintTypePhash, + Fingerprint: getFilePhash(index), + }, } } From b8bd8953f7ac2f790785b6e794a9b357c6594d82 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:56:31 +1100 Subject: [PATCH 045/152] Refactor bulk edit dialogs (#6647) * Add BulkUpdateDateInput * Refactor edit scenes dialog * Improve bulk date input styling * Make fields inline in edit performers dialog * Refactor edit images dialog * Refactor edit galleries dialog * Add date and synopsis to bulk update group input * Refactor edit groups dialog * Change edit dialog titles to 'Edit x entities' * Update styling of bulk fields to be consistent with other UI * Rename BulkUpdateTextInput to generic BulkUpdate We'll collect other bulk inputs here * Add and use BulkUpdateFormGroup * Handle null dates correctly * Add date clear button and validation --- graphql/schema/types/group.graphql | 2 + internal/api/resolver_mutation_group.go | 6 + .../Galleries/EditGalleriesDialog.tsx | 410 ++++++++--------- .../components/Groups/EditGroupsDialog.tsx | 293 ++++++------ .../components/Images/EditImagesDialog.tsx | 384 ++++++++-------- .../Performers/EditPerformersDialog.tsx | 290 +++++++----- .../Scenes/EditSceneMarkersDialog.tsx | 73 ++- .../components/Scenes/EditScenesDialog.tsx | 432 ++++++++---------- ui/v2.5/src/components/Shared/BulkUpdate.tsx | 89 ++++ .../components/Shared/BulkUpdateTextInput.tsx | 48 -- ui/v2.5/src/components/Shared/DateInput.tsx | 131 +++++- ui/v2.5/src/components/Shared/MultiSet.tsx | 14 +- ui/v2.5/src/components/Shared/styles.scss | 33 +- .../components/Studios/EditStudiosDialog.tsx | 94 ++-- .../src/components/Tags/EditTagsDialog.tsx | 44 +- ui/v2.5/src/core/StashService.ts | 6 +- ui/v2.5/src/locales/en-GB.json | 2 + ui/v2.5/src/utils/bulkUpdate.ts | 5 + ui/v2.5/src/utils/form.tsx | 2 +- ui/v2.5/src/utils/yup.ts | 50 +- 20 files changed, 1253 insertions(+), 1155 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/BulkUpdate.tsx delete mode 100644 ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a1c878923..8610f39dc 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -99,6 +99,8 @@ input BulkGroupUpdateInput { ids: [ID!] # rating expressed as 1-100 rating100: Int + date: String + synopsis: String studio_id: ID director: String urls: BulkUpdateStrings diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index dff5a6c1e..6c986c4da 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -227,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { updatedGroup := models.NewGroupPartial() + updatedGroup.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + err = fmt.Errorf("converting date: %w", err) + return + } + updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 9ff7e00f2..cec44abf1 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -1,100 +1,129 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkGalleryUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateSceneIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; onClose: (applied: boolean) => void; } +const galleryFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditGalleriesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [organized, setOrganized] = useState(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((gallery) => { + return gallery.id; + }), + }); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [sceneIds, setSceneIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateSceneIds = getAggregateSceneIds(props.selected); + let first = true; + + state.forEach((gallery: GQL.SlimGalleryDataFragment) => { + getAggregateStateObject(updateState, gallery, galleryFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + sceneIds: updateSceneIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getGalleryInput(): GQL.BulkGalleryUpdateInput { - // need to determine what we are actually setting on each gallery - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const galleryInput: GQL.BulkGalleryUpdateInput = { - ids: props.selected.map((gallery) => { - return gallery.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + scene_ids: sceneIds, }; - galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - galleryInput.studio_id = getAggregateInputValue( - studioId, - aggregateStudioId + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + galleryInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - galleryInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds - ); - galleryInput.tag_ids = getAggregateInputIDs( - tagMode, - tagIds, - aggregateTagIds - ); - - if (organized !== undefined) { - galleryInput.organized = organized; - } - return galleryInput; } async function onSave() { setIsUpdating(true); try { - await updateGalleries({ - variables: { - input: getGalleryInput(), - }, - }); + await updateGalleries({ variables: { input: getGalleryInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((gallery: GQL.SlimGalleryDataFragment) => { - const galleryRating = gallery.rating100; - const GalleriestudioID = gallery?.studio?.id; - const galleryPerformerIDs = (gallery.performers ?? []) - .map((p) => p.id) - .sort(); - const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = galleryRating ?? undefined; - updateStudioID = GalleriestudioID; - updatePerformerIds = galleryPerformerIDs; - updateTagIds = galleryTagIDs; - updateOrganized = gallery.organized; - first = false; - } else { - if (galleryRating !== updateRating) { - updateRating = undefined; - } - if (GalleriestudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(galleryPerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(galleryTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (gallery.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - } - }} - existingIds={existingIds ?? []} - ids={ids ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setSceneIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setSceneIds((c) => ({ ...c, mode: newMode })); + }} + ids={sceneIds.ids ?? []} + existingIds={aggregateState.sceneIds} + mode={sceneIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index ef3171de2..99c482aba 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -1,26 +1,26 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkGroupUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; -import { ModalComponent } from "../Shared/Modal"; import { StudioSelect } from "../Shared/Select"; +import { ModalComponent } from "../Shared/Modal"; +import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateIds, - getAggregateInputIDs, getAggregateInputValue, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import { isEqual } from "lodash-es"; -import { MultiSet } from "../Shared/MultiSet"; -import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable"; +import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.ListGroupDataFragment[]; @@ -67,50 +67,86 @@ function getAggregateContainingGroupInput( return undefined; } +const groupFields = ["rating100", "synopsis", "director", "date"]; + export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [director, setDirector] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((group) => { + return group.id; + }), + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); const [containingGroupsMode, setGroupMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [containingGroups, setGroups] = useState(); - const [existingContainingGroups, setExistingContainingGroups] = - useState(); - const [updateGroups] = useBulkGroupUpdate(getGroupInput()); + const unsetDisabled = props.selected.length < 2; + const [updateGroups] = useBulkGroupUpdate(); + + const [dateError, setDateError] = useState(); + + // Network state const [isUpdating, setIsUpdating] = useState(false); - function getGroupInput(): GQL.BulkGroupUpdateInput { - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); const aggregateGroups = getAggregateContainingGroups(props.selected); + let first = true; + state.forEach((group: GQL.ListGroupDataFragment) => { + getAggregateStateObject(updateState, group, groupFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + containingGroups: aggregateGroups, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getGroupInput(): GQL.BulkGroupUpdateInput { const groupInput: GQL.BulkGroupUpdateInput = { - ids: props.selected.map((group) => group.id), - director, + ...updateInput, + tag_ids: tagIds, }; - groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + groupInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 + ); groupInput.containing_groups = getAggregateContainingGroupInput( containingGroupsMode, containingGroups, - aggregateGroups + aggregateState.containingGroups ); return groupInput; @@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateGroups(); + await updateGroups({ variables: { input: getGroupInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, - { - entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(), - } + { entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() } ) ); props.onClose(true); @@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioId: string | undefined; - let updateTagIds: string[] = []; - let updateContainingGroupIds: IRelatedGroupEntry[] = []; - let updateDirector: string | undefined; - let first = true; - - state.forEach((group: GQL.ListGroupDataFragment) => { - const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); - const groupContainingGroupIDs = (group.containing_groups ?? []).sort( - (a, b) => a.group.id.localeCompare(b.group.id) - ); - - if (first) { - first = false; - updateRating = group.rating100 ?? undefined; - updateStudioId = group.studio?.id ?? undefined; - updateTagIds = groupTagIDs; - updateContainingGroupIds = groupContainingGroupIDs; - updateDirector = group.director ?? undefined; - } else { - if (group.rating100 !== updateRating) { - updateRating = undefined; - } - if (group.studio?.id !== updateStudioId) { - updateStudioId = undefined; - } - if (group.director !== updateDirector) { - updateDirector = undefined; - } - if (!isEqual(groupTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) { - updateTagIds = []; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioId); - setExistingTagIds(updateTagIds); - setExistingContainingGroups(updateContainingGroupIds); - setDirector(updateDirector); - }, [props.selected]); - function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + setGroups(v)} onSetMode={(newMode) => setGroupMode(newMode)} - existingValue={existingContainingGroups ?? []} + existingValue={aggregateState.containingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} menuPortalTarget={document.body} /> - - - - - - setDirector(event.currentTarget.value)} - placeholder={intl.formatMessage({ id: "director" })} - /> - - - - - + + + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} menuPortalTarget={document.body} /> - + + + + + setUpdateField({ synopsis: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> +
        ); diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 275ff1556..a90ef922e 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -1,96 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkImageUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; -import { StudioSelect } from "src/components/Shared/Select"; -import { ModalComponent } from "src/components/Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; +import { StudioSelect } from "../Shared/Select"; +import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateGalleryIds, - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateGalleryIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimImageDataFragment[]; onClose: (applied: boolean) => void; } +const imageFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditImagesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((image) => { + return image.id; + }), + }); - const [galleryMode, setGalleryMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [galleryIds, setGalleryIds] = useState(); - const [existingGalleryIds, setExistingGalleryIds] = useState(); + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [galleryIds, setGalleryIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); - const [organized, setOrganized] = useState(); + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGalleryIds = getAggregateGalleryIds(props.selected); + let first = true; + + state.forEach((image: GQL.SlimImageDataFragment) => { + getAggregateStateObject(updateState, image, imageFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + galleryIds: updateGalleryIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getImageInput(): GQL.BulkImageUpdateInput { - // need to determine what we are actually setting on each image - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGalleryIds = getAggregateGalleryIds(props.selected); - const imageInput: GQL.BulkImageUpdateInput = { - ids: props.selected.map((image) => { - return image.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + gallery_ids: galleryIds, }; - imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - imageInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + imageInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - imageInput.gallery_ids = getAggregateInputIDs( - galleryMode, - galleryIds, - aggregateGalleryIds - ); - - if (organized !== undefined) { - imageInput.organized = organized; - } return imageInput; } @@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateImages({ - variables: { - input: getImageInput(), - }, - }); + await updateImages({ variables: { input: getImageInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGalleryIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - const imageRating = image.rating100; - const imageStudioID = image?.studio?.id; - const imagePerformerIDs = (image.performers ?? []) - .map((p) => p.id) - .sort(); - const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort(); - const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = imageRating ?? undefined; - updateStudioID = imageStudioID; - updatePerformerIds = imagePerformerIDs; - updateTagIds = imageTagIDs; - updateGalleryIds = imageGalleryIDs; - updateOrganized = image.organized; - first = false; - } else { - if (imageRating !== updateRating) { - updateRating = undefined; - } - if (imageStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(imagePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(imageTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(imageGalleryIDs, updateGalleryIds)) { - updateGalleryIds = []; - } - if (image.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGalleryIds(updateGalleryIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - - - + + setUpdateField({ rating100: value ?? undefined }) + } disabled={isUpdating} - onUpdate={(itemIDs) => setPerformerIds(itemIDs)} - onSetMode={(newMode) => setPerformerMode(newMode)} - existingIds={existingPerformerIds ?? []} - ids={performerIds ?? []} - mode={performerMode} + /> + + + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} menuPortalTarget={document.body} /> - + - - - - + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} menuPortalTarget={document.body} /> - + - - - - + setGalleryIds(itemIDs)} - onSetMode={(newMode) => setGalleryMode(newMode)} - existingIds={existingGalleryIds ?? []} - ids={galleryIds ?? []} - mode={galleryMode} + onUpdate={(itemIDs) => { + setGalleryIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGalleryIds((c) => ({ ...c, mode: newMode })); + }} + ids={galleryIds.ids ?? []} + existingIds={aggregateState.galleryIds} + mode={galleryIds.mode} menuPortalTarget={document.body} /> - + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index d60118d4b..d63886167 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Col, Form, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; @@ -23,12 +23,13 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import * as FormUtils from "src/utils/form"; import { CountrySelect } from "../Shared/CountrySelect"; import { useConfigurationContext } from "src/hooks/Config"; import cx from "classnames"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -75,17 +76,30 @@ export const EditPerformersDialog: React.FC = ( const [aggregateState, setAggregateState] = useState({}); // height and weight needs conversion to/from number - const [height, setHeight] = useState(); - const [weight, setWeight] = useState(); - const [penis_length, setPenisLength] = useState(); + const [height, setHeight] = useState(); + const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); const circumcisedOptions = [""].concat(circumcisedStrings); + const unsetDisabled = props.selected.length < 2; + const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); + const [birthdateError, setBirthdateError] = useState(); + const [deathDateError, setDeathDateError] = useState(); + + useEffect(() => { + setBirthdateError(getDateError(updateInput.birthdate ?? "", intl)); + }, [updateInput.birthdate, intl]); + + useEffect(() => { + setDeathDateError(getDateError(updateInput.death_date ?? "", intl)); + }, [updateInput.death_date, intl]); + // Network state const [isUpdating, setIsUpdating] = useState(false); @@ -121,14 +135,14 @@ export const EditPerformersDialog: React.FC = ( ); if (height !== undefined) { - performerInput.height_cm = parseFloat(height); + performerInput.height_cm = height === null ? null : parseFloat(height); } if (weight !== undefined) { - performerInput.weight = parseFloat(weight); + performerInput.weight = weight === null ? null : parseFloat(weight); } - if (penis_length !== undefined) { - performerInput.penis_length = parseFloat(penis_length); + performerInput.penis_length = + penis_length === null ? null : parseFloat(penis_length); } return performerInput; @@ -205,25 +219,6 @@ export const EditPerformersDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - function render() { // sfw class needs to be set because it is outside body @@ -235,13 +230,18 @@ export const EditPerformersDialog: React.FC = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "performers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "performer" }), + pluralEntity: intl.formatMessage({ id: "performers" }), + } )} accept={{ onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!birthdateError || !!deathDateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -249,11 +249,8 @@ export const EditPerformersDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - +
        + @@ -261,9 +258,8 @@ export const EditPerformersDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -272,10 +268,7 @@ export const EditPerformersDialog: React.FC = ( /> - - - - + = ( ))} - + - {renderTextField("disambiguation", updateInput.disambiguation, (v) => - setUpdateField({ disambiguation: v }) - )} - {renderTextField("birthdate", updateInput.birthdate, (v) => - setUpdateField({ birthdate: v }) - )} - {renderTextField("death_date", updateInput.death_date, (v) => - setUpdateField({ death_date: v }) - )} + + + setUpdateField({ disambiguation: newValue }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + + + setUpdateField({ birthdate: newValue }) + } + unsetDisabled={unsetDisabled} + error={birthdateError} + /> + + + + setUpdateField({ death_date: newValue }) + } + unsetDisabled={unsetDisabled} + error={deathDateError} + /> + + setUpdateField({ country: v })} showFlag /> - + - {renderTextField("ethnicity", updateInput.ethnicity, (v) => - setUpdateField({ ethnicity: v }) - )} - {renderTextField("hair_color", updateInput.hair_color, (v) => - setUpdateField({ hair_color: v }) - )} - {renderTextField("eye_color", updateInput.eye_color, (v) => - setUpdateField({ eye_color: v }) - )} - {renderTextField("height", height, (v) => setHeight(v))} - {renderTextField("weight", weight, (v) => setWeight(v))} - {renderTextField("measurements", updateInput.measurements, (v) => - setUpdateField({ measurements: v }) - )} - {renderTextField("penis_length", penis_length, (v) => - setPenisLength(v) - )} + + + setUpdateField({ ethnicity: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ hair_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ eye_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setHeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + setWeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ measurements: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setPenisLength(newValue)} + unsetDisabled={unsetDisabled} + /> + - - - - + = ( ))} - + - {renderTextField("fake_tits", updateInput.fake_tits, (v) => - setUpdateField({ fake_tits: v }) - )} - {renderTextField("tattoos", updateInput.tattoos, (v) => - setUpdateField({ tattoos: v }) - )} - {renderTextField("piercings", updateInput.piercings, (v) => - setUpdateField({ piercings: v }) - )} - {renderTextField( - "career_start", - updateInput.career_start?.toString(), - (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined }) - )} - {renderTextField( - "career_end", - updateInput.career_end?.toString(), - (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined }) - )} + + + setUpdateField({ fake_tits: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ tattoos: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ piercings: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_start: v ? parseInt(v) : undefined }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_end: v ? parseInt(v) : undefined }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + setTagIds({ ...tagIds, ids: itemIDs })} - onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })} - existingIds={existingTagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={existingTagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); // Network state @@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "markers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "marker" }), + pluralEntity: intl.formatMessage({ id: "markers" }), + } )} accept={{ onClick: onSave, @@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC = ( isRunning={isUpdating} > - {renderTextField("title", updateInput.title, (newValue) => - setUpdateField({ title: newValue }) - )} + + setUpdateField({ title: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - + setUpdateField({ primary_tag_id: t[0]?.id })} ids={ updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] } /> - + - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds ?? []} mode={tagIds.mode} menuPortalTarget={document.body} /> - + ); diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 7b69cf655..17466bfc9 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -1,93 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkSceneUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregateGroupIds, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; onClose: (applied: boolean) => void; } +const sceneFields = [ + "code", + "rating100", + "details", + "organized", + "director", + "date", +]; + export const EditScenesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [groupMode, setGroupMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [groupIds, setGroupIds] = useState(); - const [existingGroupIds, setExistingGroupIds] = useState(); - const [organized, setOrganized] = useState(); - const [updateScenes] = useBulkSceneUpdate(getSceneInput()); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((scene) => { + return scene.id; + }), + }); + + const [dateError, setDateError] = useState(); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [groupIds, setGroupIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [updateScenes] = useBulkSceneUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGroupIds = getAggregateGroupIds(props.selected); + let first = true; + + state.forEach((scene: GQL.SlimSceneDataFragment) => { + getAggregateStateObject(updateState, scene, sceneFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + groupIds: updateGroupIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getSceneInput(): GQL.BulkSceneUpdateInput { - // need to determine what we are actually setting on each scene - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGroupIds = getAggregateGroupIds(props.selected); - const sceneInput: GQL.BulkSceneUpdateInput = { - ids: props.selected.map((scene) => { - return scene.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + group_ids: groupIds, }; - sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - sceneInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + sceneInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - sceneInput.group_ids = getAggregateInputIDs( - groupMode, - groupIds, - aggregateGroupIds - ); - - if (organized !== undefined) { - sceneInput.organized = organized; - } return sceneInput; } @@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateScenes(); + await updateScenes({ variables: { input: getSceneInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGroupIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((scene: GQL.SlimSceneDataFragment) => { - const sceneRating = scene.rating100; - const sceneStudioID = scene?.studio?.id; - const scenePerformerIDs = (scene.performers ?? []) - .map((p) => p.id) - .sort(); - const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort(); - - if (first) { - updateRating = sceneRating ?? undefined; - updateStudioID = sceneStudioID; - updatePerformerIds = scenePerformerIDs; - updateTagIds = sceneTagIDs; - updateGroupIds = sceneGroupIDs; - first = false; - updateOrganized = scene.organized; - } else { - if (sceneRating !== updateRating) { - updateRating = undefined; - } - if (sceneStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(scenePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(sceneTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(sceneGroupIDs, updateGroupIds)) { - updateGroupIds = []; - } - if (scene.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGroupIds(updateGroupIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags" | "groups", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - case "groups": - mode = groupMode; - existingIds = existingGroupIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - case "groups": - setGroupIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - case "groups": - setGroupMode(newMode); - break; - } - }} - ids={ids ?? []} - existingIds={existingIds ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("groups", groupIds)} - + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setGroupIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGroupIds((c) => ({ ...c, mode: newMode })); + }} + ids={groupIds.ids ?? []} + existingIds={aggregateState.groupIds} + mode={groupIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Shared/BulkUpdate.tsx b/ui/v2.5/src/components/Shared/BulkUpdate.tsx new file mode 100644 index 000000000..8a1b7c884 --- /dev/null +++ b/ui/v2.5/src/components/Shared/BulkUpdate.tsx @@ -0,0 +1,89 @@ +import { faBan } from "@fortawesome/free-solid-svg-icons"; +import React from "react"; +import { + Button, + Col, + Form, + FormControlProps, + InputGroup, + Row, +} from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import * as FormUtils from "src/utils/form"; + +interface IBulkUpdateTextInputProps extends Omit { + valueChanged: (value: string | null | undefined) => void; + value: string | null | undefined; + unsetDisabled?: boolean; + as?: React.ElementType; +} + +export const BulkUpdateTextInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const value = props.value === null ? "" : props.value ?? undefined; + const unset = value === undefined; + + const placeholderValue = unset + ? `<${intl.formatMessage({ id: "existing_value" })}>` + : value === "" + ? `<${intl.formatMessage({ id: "empty_value" })}>` + : undefined; + + return ( + + valueChanged(event.currentTarget.value)} + /> + + {!unsetDisabled ? ( + + ) : undefined} + + + ); +}; + +export const BulkUpdateFormGroup: React.FC<{ + name: string; + messageId?: string; + inline?: boolean; +}> = ({ name, messageId = name, inline = true, children }) => { + if (inline) { + return ( + + {FormUtils.renderLabel({ + title: , + })} + {children} + + ); + } + + return ( + + + + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx deleted file mode 100644 index cf78798e1..000000000 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { faBan } from "@fortawesome/free-solid-svg-icons"; -import React from "react"; -import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap"; -import { useIntl } from "react-intl"; -import { Icon } from "./Icon"; - -interface IBulkUpdateTextInputProps extends FormControlProps { - valueChanged: (value: string | undefined) => void; - unsetDisabled?: boolean; - as?: React.ElementType; -} - -export const BulkUpdateTextInput: React.FC = ({ - valueChanged, - unsetDisabled, - ...props -}) => { - const intl = useIntl(); - - const unsetClassName = props.value === undefined ? "unset" : ""; - - return ( - - ` - : undefined - } - onChange={(event) => valueChanged(event.currentTarget.value)} - /> - {!unsetDisabled ? ( - - ) : undefined} - - ); -}; diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index 15a0f1123..4bb39ac39 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -8,14 +8,20 @@ import { Icon } from "./Icon"; import "react-datepicker/dist/react-datepicker.css"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; +import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons"; interface IProps { + groupClassName?: string; + className?: string; disabled?: boolean; value: string; isTime?: boolean; onValueChange(value: string): void; placeholder?: string; + placeholderOverride?: string; error?: string; + appendBefore?: React.ReactNode; + appendAfter?: React.ReactNode; } const ShowPickerButton = forwardRef< @@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef< const _DateInput: React.FC = (props: IProps) => { const intl = useIntl(); + const { + groupClassName = "date-input-group", + className = "date-input text-input", + } = props; + const date = useMemo(() => { const toDate = props.isTime ? TextUtils.stringToFuzzyDateTime @@ -70,34 +81,108 @@ const _DateInput: React.FC = (props: IProps) => { } } - const placeholderText = intl.formatMessage({ + const formatHint = intl.formatMessage({ id: props.isTime ? "datetime_format" : "date_format", }); + const placeholderText = props.placeholder + ? `${props.placeholder} (${formatHint})` + : formatHint; + return ( -
        - - props.onValueChange(e.currentTarget.value)} - placeholder={ - !props.disabled - ? props.placeholder - ? `${props.placeholder} (${placeholderText})` - : placeholderText - : undefined - } - isInvalid={!!props.error} - /> - {maybeRenderButton()} - - {props.error} - - -
        + + props.onValueChange(e.currentTarget.value)} + placeholder={ + !props.disabled + ? props.placeholderOverride ?? placeholderText + : undefined + } + isInvalid={!!props.error} + /> + + {props.appendBefore} + {maybeRenderButton()} + {props.appendAfter} + + + {props.error} + + ); }; export const DateInput = PatchComponent("DateInput", _DateInput); + +interface IBulkUpdateDateInputProps + extends Omit { + value: string | null | undefined; + valueChanged: (value: string | null | undefined) => void; + unsetDisabled?: boolean; + as?: React.ElementType; + error?: string; +} + +export const BulkUpdateDateInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const unset = props.value === undefined; + + const unsetButton = !unsetDisabled ? ( + + ) : undefined; + + const clearButton = + props.value !== null ? ( + + ) : undefined; + + const placeholderValue = + props.value === null + ? `<${intl.formatMessage({ id: "empty_value" })}>` + : props.value === undefined + ? `<${intl.formatMessage({ id: "existing_value" })}>` + : undefined; + + function outValue(v: string | undefined) { + if (v === "") { + return null; + } + + return v; + } + + return ( + valueChanged(outValue(v))} + groupClassName="bulk-update-date-input" + className="date-input text-input" + appendBefore={clearButton} + appendAfter={unsetButton} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 6be85b8b3..8f16bd716 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { TagIDSelect } from "../Tags/TagSelect"; import { GroupIDSelect } from "../Groups/GroupSelect"; +import { SceneIDSelect } from "../Scenes/SceneSelect"; interface IMultiSetProps { - type: "performers" | "studios" | "tags" | "groups" | "galleries"; + type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; @@ -89,6 +90,17 @@ const Select: React.FC = (props) => { menuPortalTarget={props.menuPortalTarget} /> ); + case "scenes": + return ( + + ); default: return ( = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateStudios] = useBulkStudioUpdate(); // Network state @@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "studios" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "studio" }), + pluralEntity: intl.formatMessage({ id: "studios" }), + } )} accept={{ onClick: onSave, @@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "parent_studio" }), - })} - +
        + setUpdateField({ @@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC = ( isDisabled={isUpdating} menuPortalTarget={document.body} /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - + + @@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC = ( /> - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + - {renderTextField( - "details", - updateInput.details, - (newValue) => setUpdateField({ details: newValue }), - true - )} + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + = ( const [updateInput, setUpdateInput] = useState({}); + const unsetDisabled = props.selected.length < 2; + const [updateTags] = useBulkTagUpdate(getTagInput()); // Network state @@ -153,33 +155,18 @@ export const EditTagsDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - return ( = ( /> - {renderTextField("description", updateInput.description, (v) => - setUpdateField({ description: v }) - )} + + + setUpdateField({ description: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> + }, }); -export const useBulkSceneUpdate = (input: GQL.BulkSceneUpdateInput) => +export const useBulkSceneUpdate = () => GQL.useBulkSceneUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkSceneUpdate) return; @@ -1403,9 +1402,8 @@ export const useGroupUpdate = () => }, }); -export const useBulkGroupUpdate = (input: GQL.BulkGroupUpdateInput) => +export const useBulkGroupUpdate = () => GQL.useBulkGroupUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkGroupUpdate) return; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7b4091f8b..3c3fd4f28 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -985,6 +985,7 @@ "delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "dont_show_until_updated": "Don't show until next update", "edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "edit_entity_count_title": "Edit {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Include related objects in export", "export_title": "Export", "imagewall": { @@ -1147,6 +1148,7 @@ "warmth": "Warmth" }, "empty_server": "Add some scenes to your server to view recommendations on this page.", + "empty_value": "empty", "errors": { "custom_fields": { "duplicate_field": "Field name must be unique", diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 1ded76c27..c667b231b 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -81,6 +81,11 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) { return getAggregateIds(sortedLists); } +export function getAggregateSceneIds(state: { scenes: IHasID[] }[]) { + const sortedLists = state.map((o) => o.scenes.map((oo) => oo.id).sort()); + return getAggregateIds(sortedLists); +} + interface IGroup { group: IHasID; } diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index fbf239a9b..7c804e221 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -33,7 +33,7 @@ function getLabelProps(labelProps?: FormLabelProps) { } export function renderLabel(options: { - title: string; + title: React.ReactNode; labelProps?: FormLabelProps; }) { return ( diff --git a/ui/v2.5/src/utils/yup.ts b/ui/v2.5/src/utils/yup.ts index a9c4f69e1..912886858 100644 --- a/ui/v2.5/src/utils/yup.ts +++ b/ui/v2.5/src/utils/yup.ts @@ -92,6 +92,37 @@ export function yupUniqueStringList(intl: IntlShape) { }); } +export function validateDateString(value?: string) { + if (!value) return true; + // Allow YYYY, YYYY-MM, or YYYY-MM-DD formats + if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false; + // Validate the date components + const parts = value.split("-"); + const year = parseInt(parts[0], 10); + if (year < 1 || year > 9999) return false; + if (parts.length >= 2) { + const month = parseInt(parts[1], 10); + if (month < 1 || month > 12) return false; + } + if (parts.length === 3) { + const day = parseInt(parts[2], 10); + if (day < 1 || day > 31) return false; + // Full date - validate it parses correctly + if (Number.isNaN(Date.parse(value))) return false; + } + return true; +} + +export function getDateError( + value: string | undefined | null, + intl: IntlShape +) { + if (validateDateString(value ?? "")) return undefined; + return intl + .formatMessage({ id: "validation.date_invalid_form" }) + .replace("${path}", intl.formatMessage({ id: "date" })); +} + export function yupDateString(intl: IntlShape) { return yup .string() @@ -99,24 +130,7 @@ export function yupDateString(intl: IntlShape) { .test({ name: "date", test(value) { - if (!value) return true; - // Allow YYYY, YYYY-MM, or YYYY-MM-DD formats - if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false; - // Validate the date components - const parts = value.split("-"); - const year = parseInt(parts[0], 10); - if (year < 1 || year > 9999) return false; - if (parts.length >= 2) { - const month = parseInt(parts[1], 10); - if (month < 1 || month > 12) return false; - } - if (parts.length === 3) { - const day = parseInt(parts[2], 10); - if (day < 1 || day > 31) return false; - // Full date - validate it parses correctly - if (Number.isNaN(Date.parse(value))) return false; - } - return true; + return validateDateString(value); }, message: intl.formatMessage({ id: "validation.date_invalid_form" }), }); From b4fab0ac48732b3e3cb20d571f6fd8a0edac120d Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:34:57 -0700 Subject: [PATCH 046/152] Add parent tag hierarchy support to tag tagger (#6620) --- graphql/schema/types/scraper.graphql | 1 + graphql/stash-box/query.graphql | 5 + internal/manager/manager_tasks.go | 6 +- internal/manager/task_stash_box_tag.go | 42 ++- pkg/match/scraped.go | 14 + pkg/models/model_scraped_item.go | 28 +- pkg/stashbox/graphql/generated_client.go | 215 ++++++++++++- pkg/stashbox/tag.go | 7 + ui/v2.5/graphql/data/scrapers.graphql | 5 + ui/v2.5/src/components/Shared/BatchModals.tsx | 242 ++++++++++++++ ui/v2.5/src/components/Tagger/constants.ts | 4 +- .../Tagger/studios/StudioTagger.tsx | 266 ++-------------- ui/v2.5/src/components/Tagger/styles.scss | 6 +- .../Tagger/tags/StashSearchResult.tsx | 59 +++- .../src/components/Tagger/tags/TagModal.tsx | 150 ++++++++- .../src/components/Tagger/tags/TagTagger.tsx | 295 +++++------------- ui/v2.5/src/locales/en-GB.json | 6 + 17 files changed, 867 insertions(+), 484 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/BatchModals.tsx diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index b8810aa79..fafd928f7 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -73,6 +73,7 @@ type ScrapedTag { name: String! description: String alias_list: [String!] + parent: ScrapedTag "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index edd44c835..ebaf05648 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -31,6 +31,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment MeasurementsFragment on Measurements { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index c9e840519..e3529c0b8 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -431,7 +431,7 @@ type StashBoxBatchTagInput struct { ExcludeFields []string `json:"exclude_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` - // If batch adding studios, should their parent studios also be created? + // If batch adding studios or tags, should their parent entities also be created? CreateParent bool `json:"createParent"` // IDs in stash of the items to update. // If set, names and stash_ids fields will be ignored. @@ -749,6 +749,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -769,6 +770,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box if len(stashID) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ stashID: &stashID, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -780,6 +782,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box if len(name) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ name: &name, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -806,6 +809,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp for _, t := range tags { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 97c766010..ec17fac06 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -541,6 +541,7 @@ type stashBoxBatchTagTagTask struct { name *string stashID *string tag *models.Tag + createParent bool excludedFields []string } @@ -630,7 +631,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. result := results[0] if err := r.WithReadTxn(ctx, func(ctx context.Context) error { - return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint) + return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) }); err != nil { return nil, err } @@ -638,6 +639,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. return result, nil } +func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error { + if parent.StoredID == nil { + // Create new parent tag + newParentTag := parent.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil { + return err + } + + storedID := strconv.Itoa(newParentTag.ID) + parent.StoredID = &storedID + return nil + }) + if err != nil { + logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err) + } else { + logger.Infof("Created parent tag %s", parent.Name) + } + return err + } + + // Parent already exists — nothing to update for categories + return nil +} + func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { // Determine the tag ID to update — either from the task's tag or from the // StoredID set by match.ScrapedTag (when batch adding by name and the tag @@ -649,6 +683,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode tagID, _ = strconv.Atoi(*s.StoredID) } + if s.Parent != nil && t.createParent { + if err := t.processParentTag(ctx, s.Parent, excluded); err != nil { + return + } + } + if tagID > 0 { r := instance.Repository err := r.WithTxn(ctx, func(ctx context.Context) error { diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index d3039f4c6..a6683ff52 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na return } +// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent. +func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { + if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil { + return err + } + + if s.Parent == nil { + return nil + } + + // Match parent by name only (categories don't have StashDB tag IDs) + return ScrapedTag(ctx, qb, s.Parent, "") +} + // ScrapedTag matches the provided tag with the tags // in the database and sets the ID field if one is found. func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 1367003cb..1a64d0849 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -471,11 +471,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - Description *string `json:"description"` - AliasList []string `json:"alias_list"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + Description *string `json:"description"` + AliasList []string `json:"alias_list"` + RemoteSiteID *string `json:"remote_site_id"` + Parent *ScrapedTag `json:"parent"` } func (ScrapedTag) IsScrapedContent() {} @@ -496,6 +497,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { ret.Aliases = NewRelatedStrings(t.AliasList) } + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = NewRelatedIDs([]int{parentID}) + } + } + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { @@ -527,6 +535,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st } } + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = &UpdateIDs{ + IDs: []int{parentID}, + Mode: RelationshipUpdateModeAdd, + } + } + } + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index acb2202dc..bc9a6ce89 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -128,10 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment { } type TagFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - Description *string "json:\"description,omitempty\" graphql:\"description\"" - Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\"" } func (t *TagFragment) GetName() string { @@ -158,6 +159,12 @@ func (t *TagFragment) GetAliases() []string { } return t.Aliases } +func (t *TagFragment) GetCategory() *TagFragment_Category { + if t == nil { + t = &TagFragment{} + } + return t.Category +} type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" @@ -530,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string { return t.Name } +type TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *TagFragment_Category) GetDescription() *string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Description +} +func (t *TagFragment_Category) GetID() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.ID +} +func (t *TagFragment_Category) GetName() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Name +} + type SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -548,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { return t.Name } +type SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -566,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme return t.Name } +type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -584,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -602,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindStudio_FindStudio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -620,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type FindTag_FindTag_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Description +} +func (t *FindTag_FindTag_TagFragment_Category) GetID() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.ID +} +func (t *FindTag_FindTag_TagFragment_Category) GetName() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Name +} + +type QueryTags_QueryTags_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Name +} + type QueryTags_QueryTags struct { Count int "json:\"count\" graphql:\"count\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" @@ -865,6 +1047,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1003,6 +1190,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1299,6 +1491,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1435,6 +1632,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } ` @@ -1469,6 +1671,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } ` diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index 452dd9928..45bcf96c4 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -72,5 +72,12 @@ func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { ret.AliasList = t.Aliases } + if t.Category != nil { + ret.Parent = &models.ScrapedTag{ + Name: t.Category.Name, + Description: t.Category.Description, + } + } + return ret } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 7214c2064..0dae3c2d5 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag { name description alias_list + parent { + stored_id + name + description + } remote_site_id } diff --git a/ui/v2.5/src/components/Shared/BatchModals.tsx b/ui/v2.5/src/components/Shared/BatchModals.tsx new file mode 100644 index 000000000..0de8f5e1f --- /dev/null +++ b/ui/v2.5/src/components/Shared/BatchModals.tsx @@ -0,0 +1,242 @@ +import React, { useMemo, useRef, useState } from "react"; +import { Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ModalComponent } from "src/components/Shared/Modal"; +import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; + +interface IEntityWithStashIDs { + stash_ids: { endpoint: string }[]; +} + +interface IBatchUpdateModalProps { + entities: IEntityWithStashIDs[]; + isIdle: boolean; + selectedEndpoint: { endpoint: string; index: number }; + allCount: number | undefined; + onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; + onRefreshChange?: (refresh: boolean) => void; + batchAddParents: boolean; + setBatchAddParents: (addParents: boolean) => void; + close: () => void; + localePrefix: string; + entityName: string; + countVariableName: string; +} + +export const BatchUpdateModal: React.FC = ({ + entities, + isIdle, + selectedEndpoint, + allCount, + onBatchUpdate, + onRefreshChange, + batchAddParents, + setBatchAddParents, + close, + localePrefix, + entityName, + countVariableName, +}) => { + const intl = useIntl(); + + const [queryAll, setQueryAll] = useState(false); + const [refresh, setRefreshState] = useState(false); + + const setRefresh = (value: boolean) => { + setRefreshState(value); + onRefreshChange?.(value); + }; + + const entityCount = useMemo(() => { + const filteredStashIDs = entities.map((e) => + e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) + ); + + return queryAll + ? allCount + : filteredStashIDs.filter((s) => + refresh ? s.length > 0 : s.length === 0 + ).length; + }, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]); + + return ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
        + +
        +
        + } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
        + + +
        + +
        +
        + setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
        +
        + setBatchAddParents(!batchAddParents)} + /> +
        + + + +
        + ); +}; + +interface IBatchAddModalProps { + isIdle: boolean; + onBatchAdd: (input: string) => void; + batchAddParents: boolean; + setBatchAddParents: (addParents: boolean) => void; + close: () => void; + localePrefix: string; + entityName: string; +} + +export const BatchAddModal: React.FC = ({ + isIdle, + onBatchAdd, + batchAddParents, + setBatchAddParents, + close, + localePrefix, + entityName, +}) => { + const intl = useIntl(); + + const inputRef = useRef(null); + + return ( + { + if (inputRef.current) { + onBatchAdd(inputRef.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + +
        + setBatchAddParents(!batchAddParents)} + /> +
        +
        + ); +}; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index af9afcefb..646dbf4c3 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -38,6 +38,7 @@ export const initialConfig: ITaggerConfig = { excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, + createParentTags: true, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; @@ -56,6 +57,7 @@ export interface ITaggerConfig { excludedStudioFields?: string[]; excludedTagFields?: string[]; createParentStudios: boolean; + createParentTags: boolean; } export const PERFORMER_FIELDS = [ @@ -85,4 +87,4 @@ export const PERFORMER_FIELDS = [ ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; -export const TAG_FIELDS = ["name", "description", "aliases"]; +export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"]; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 64bb99b72..adc58cc04 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxStudioQuery, useJobsSubscribe, @@ -25,11 +24,15 @@ import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, @@ -38,232 +41,6 @@ type JobFragment = Pick< const CLASSNAME = "StudioTagger"; -interface IStudioBatchUpdateModal { - studios: GQL.StudioDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchUpdateModal: React.FC = ({ - studios, - isIdle, - selectedEndpoint, - onBatchUpdate, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allStudios } = GQL.useFindStudiosQuery({ - variables: { - studio_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const studioCount = useMemo(() => { - // get all stash ids for the selected endpoint - const filteredStashIDs = studios.map((p) => - p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allStudios?.findStudios.count - : filteredStashIDs.filter((s) => - // if refresh, then we filter out the studios without a stash id - // otherwise, we want untagged studios, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
        - -
        -
        - } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
        - - -
        - -
        -
        - setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
        - setBatchAddParents(!batchAddParents)} - /> -
        -
        - - - -
        - ); -}; - -interface IStudioBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const studioInput = useRef(null); - - return ( - { - if (studioInput.current) { - onBatchAdd(studioInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - -
        - setBatchAddParents(!batchAddParents)} - /> -
        -
        - ); -}; - interface IStudioTaggerListProps { studios: GQL.StudioDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; @@ -305,6 +82,24 @@ const StudioTaggerList: React.FC = ({ config.createParentStudios || false ); + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allStudios } = GQL.useFindStudiosQuery({ + skip: !showBatchUpdate, + variables: { + studio_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [error, setError] = useState< Record >({}); @@ -630,24 +425,31 @@ const StudioTaggerList: React.FC = ({ return ( {showBatchUpdate && ( - setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} - studios={studios} + entities={studios} + allCount={allStudios?.findStudios.count} onBatchUpdate={handleBatchUpdate} + onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" + countVariableName="studio_count" /> )} {showBatchAdd && ( - setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" /> )}
        diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 5f6ece37d..1c05e574f 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -287,7 +287,8 @@ } } -.StudioTagger { +.StudioTagger, +.TagTagger { display: flex; flex-wrap: wrap; justify-content: center; @@ -342,7 +343,8 @@ vertical-align: bottom; } - &-studio-search { + &-studio-search, + &-tag-search { display: flex; flex-wrap: wrap; diff --git a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx index cd6abca02..55b86c931 100644 --- a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx @@ -7,6 +7,8 @@ import TagModal from "./TagModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { mergeTagStashIDs } from "../utils"; +import { useTagCreate } from "src/core/StashService"; +import { apolloError } from "src/utils"; interface IStashSearchResultProps { tag: GQL.TagListDataFragment; @@ -34,13 +36,49 @@ const StashSearchResult: React.FC = ({ {} ); + const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); - const handleSave = async (input: GQL.TagCreateInput) => { + function handleSaveError(name: string, message: string) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }); + } + + const handleSave = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { setError({}); setModalTag(undefined); - setSaveState("Saving tag"); + if (parentInput) { + setSaveState("Saving parent tag"); + + try { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + input.parent_ids = [parentRes.data?.tagCreate?.id].filter( + Boolean + ) as string[]; + } catch (e) { + handleSaveError(parentInput.name, apolloError(e)); + setSaveState(""); + return; + } + } + + setSaveState("Saving tag"); const updateData: GQL.TagUpdateInput = { ...input, id: tag.id, @@ -54,18 +92,7 @@ const StashSearchResult: React.FC = ({ const res = await updateTag(updateData); if (!res?.data?.tagUpdate) { - setError({ - message: intl.formatMessage( - { id: "tag_tagger.failed_to_save_tag" }, - { tag: input.name ?? tag.name } - ), - details: - res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name" - ? intl.formatMessage({ - id: "tag_tagger.name_already_exists", - }) - : res?.errors?.[0]?.message ?? "", - }); + handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? ""); } else { onTagTagged(tag); } @@ -74,7 +101,7 @@ const StashSearchResult: React.FC = ({ const tags = stashboxTags.map((p) => ( + {isSelectable && ( + + )} : @@ -85,15 +124,82 @@ const TagModal: React.FC = ({ ); } + function maybeRenderParentField( + id: string, + text: string | null | undefined, + isSelectable: boolean = true + ) { + if (!text) return; + + return ( +
        +
        + {isSelectable && ( + + )} + + : + +
        + +
        + ); + } + + function maybeRenderParentTagDetails() { + if (!createParentTag || !tag.parent) { + return; + } + + return ( +
        + {maybeRenderParentField("name", tag.parent.name, false)} + {maybeRenderParentField("description", tag.parent.description)} +
        + ); + } + + function maybeRenderParentTag() { + // No parent tag, or parent already exists locally + if (!tag.parent || tag.parent.stored_id || !sendParentTag) { + return; + } + + return ( +
        +
        + setCreateParentTag(!createParentTag)} + /> +
        + {maybeRenderParentTagDetails()} +
        + ); + } + function handleSave() { if (!tag.name) { throw new Error("tag name must be set"); } + const parentId = tag.parent?.stored_id ?? existingParentId; + const tagData: GQL.TagCreateInput = { name: tag.name, description: tag.description ?? undefined, aliases: tag.alias_list?.filter((a) => a) ?? undefined, + parent_ids: parentId ? [parentId] : undefined, }; // stashid handling code @@ -111,7 +217,27 @@ const TagModal: React.FC = ({ // handle exclusions excludeFields(tagData, excluded); - onSave(tagData); + let parentData: GQL.TagCreateInput | undefined = undefined; + + // Categories don't have stash IDs, so we only create new parent tags + if ( + createParentTag && + sendParentTag && + tag.parent && + !tag.parent.stored_id + ) { + parentData = { + name: tag.parent.name, + description: tag.parent.description ?? undefined, + }; + + // handle exclusions + // Can't exclude parent tag name when creating a new one + parentExcluded.name = false; + excludeFields(parentData, parentExcluded); + } + + onSave(tagData, parentData); } return ( @@ -133,10 +259,12 @@ const TagModal: React.FC = ({ {maybeRenderField("name", tag.name)} {maybeRenderField("description", tag.description)} {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderField("parent_tags", tag.parent?.name, false)} {maybeRenderStashBoxLink()}
    + {maybeRenderParentTag()} ); }; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 1113bdfd4..21891724c 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxTagQuery, useJobsSubscribe, @@ -20,221 +19,33 @@ import StashSearchResult from "./StashSearchResult"; import TaggerConfig from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeTagStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; -const CLASSNAME = "StudioTagger"; - -interface ITagBatchUpdateModal { - tags: GQL.TagListDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - close: () => void; -} - -const TagBatchUpdateModal: React.FC = ({ - tags, - isIdle, - selectedEndpoint, - onBatchUpdate, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allTags } = GQL.useFindTagsQuery({ - variables: { - tag_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const tagCount = useMemo(() => { - const filteredStashIDs = tags.map((t) => - t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allTags?.findTags.count - : filteredStashIDs.filter((s) => - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
    - -
    -
    - } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
    - - -
    - -
    -
    - setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
    - - - -
    - ); -}; - -interface ITagBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - close: () => void; -} - -const TagBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - close, -}) => { - const intl = useIntl(); - - const tagInput = useRef(null); - - return ( - { - if (tagInput.current) { - onBatchAdd(tagInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - - - ); -}; +const CLASSNAME = "TagTagger"; interface ITagTaggerListProps { tags: GQL.TagListDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; - onBatchAdd: (tagInput: string) => void; - onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; + onBatchAdd: (tagInput: string, createParent: boolean) => void; + onBatchUpdate: ( + ids: string[] | undefined, + refresh: boolean, + createParent: boolean + ) => void; } const TagTaggerList: React.FC = ({ @@ -261,6 +72,27 @@ const TagTaggerList: React.FC = ({ const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const [batchAddParents, setBatchAddParents] = useState( + config.createParentTags || false + ); + + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + skip: !showBatchUpdate, + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); const [error, setError] = useState< Record @@ -360,12 +192,16 @@ const TagTaggerList: React.FC = ({ }; async function handleBatchAdd(input: string) { - onBatchAdd(input); + onBatchAdd(input, batchAddParents); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { - onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh); + onBatchUpdate( + !queryAll ? tags.map((t) => t.id) : undefined, + refresh, + batchAddParents + ); setShowBatchUpdate(false); }; @@ -451,7 +287,7 @@ const TagTaggerList: React.FC = ({ subContent = (
    - + {link} - - - - - - :{" "} - - - -
    -
    - ); - }); - - return
    {tagElements}
    ; + return ( + + ); } if (filter.displayMode === DisplayMode.Tagger) { return ; @@ -287,7 +199,6 @@ export const FilteredTagList = PatchComponent( (props: ITagList) => { const intl = useIntl(); const history = useHistory(); - const Toast = useToast(); const searchFocus = useFocus(); @@ -433,16 +344,6 @@ export const FilteredTagList = PatchComponent( ); } - async function onAutoTag(tag: GQL.TagListDataFragment) { - if (!tag) return; - try { - await mutateMetadataAutoTag({ tags: [tag.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - const convertedExtraOperations = extraOperations.map((op) => ({ text: op.text, onClick: () => op.onClick(result, filter, selectedIds), @@ -566,8 +467,6 @@ export const FilteredTagList = PatchComponent( tags={items} selectedIds={selectedIds} onSelectChange={onSelectChange} - onDelete={(tag) => onDelete(tag)} - onAutoTag={(tag) => onAutoTag(tag)} /> diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx new file mode 100644 index 000000000..f593c0d1f --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -0,0 +1,230 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React from "react"; +import { useIntl } from "react-intl"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import NavUtils from "src/utils/navigation"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import { useTagUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import cx from "classnames"; +import { IColumn, ListTable } from "../List/ListTable"; + +interface ITagListTableProps { + tags: GQL.TagListDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "tags"; + +export const TagListTable: React.FC = ( + props: ITagListTableProps +) => { + const intl = useIntl(); + + const [updateTag] = useTagUpdate(); + + function setFavorite(v: boolean, tagId: string) { + if (tagId) { + updateTag({ + variables: { + input: { + id: tagId, + favorite: v, + }, + }, + }); + } + } + + const ImageCell = (tag: GQL.TagListDataFragment) => ( + + {tag.name + + ); + + const NameCell = (tag: GQL.TagListDataFragment) => ( + +
    + {tag.name} +
    + + ); + + const AliasesCell = (tag: GQL.TagListDataFragment) => { + let aliases = tag.aliases ? tag.aliases.join(", ") : ""; + return ( + + {aliases} + + ); + }; + + const FavoriteCell = (tag: GQL.TagListDataFragment) => ( + + ); + + const SceneCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.scene_count} + + ); + + const GalleryCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.gallery_count} + + ); + + const ImageCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.image_count} + + ); + + const GroupCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.group_count} + + ); + + const StudioCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.studio_count} + + ); + + const PerformerCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.performer_count} + + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "group_count", + label: intl.formatMessage({ id: "groups" }), + defaultShow: true, + render: GroupCountCell, + }, + { + value: "performer_count", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformerCountCell, + }, + { + value: "studio_count", + label: intl.formatMessage({ id: "studios" }), + defaultShow: true, + render: StudioCountCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (tag: GQL.TagListDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + tag: GQL.TagListDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(tag, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +}; From b47134112a8c7fb078fd70438e916e890daba2a0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:51:04 +1100 Subject: [PATCH 062/152] Focus search field when clicking on scraper menu (#6704) * Focus search field when opening scraper menu * Improve styling of search header in scraper menu --- ui/v2.5/src/components/Shared/ScraperMenu.tsx | 36 +++++++++++-------- ui/v2.5/src/components/Shared/styles.scss | 7 ++-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx index 4cc38b6f8..9bdb84d45 100644 --- a/ui/v2.5/src/components/Shared/ScraperMenu.tsx +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -6,6 +6,8 @@ import { stashboxDisplayName } from "src/utils/stashbox"; import { ScraperSourceInput, StashBox } from "src/core/generated-graphql"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { ClearableInput } from "./ClearableInput"; +import useFocus from "src/utils/focus"; +import ScreenUtils from "src/utils/screen"; export const ScraperMenu: React.FC<{ toggle: React.ReactNode; @@ -25,6 +27,10 @@ export const ScraperMenu: React.FC<{ const intl = useIntl(); const [filter, setFilter] = useState(""); + const focusOnOpen = !ScreenUtils.isTouch(); + const focusRef = useFocus(); + const [, setFocus] = focusRef; + const filteredStashboxes = useMemo(() => { if (!stashBoxes) return []; if (!filter) return stashBoxes; @@ -48,25 +54,27 @@ export const ScraperMenu: React.FC<{ { + if (focusOnOpen && v) setTimeout(() => setFocus(true), 0); + }} > {toggle}
    -
    - - -
    + +
    {filteredStashboxes.map((s, index) => ( diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 709712231..21e6eb696 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -766,15 +766,14 @@ button.btn.favorite-button { .scraper-filter-container { background-color: $secondary; border-bottom: solid 1px $textfield-bg; + display: flex; padding: 5px; position: sticky; top: 0; z-index: 1; - .btn-group { - border: solid 1px $textfield-bg; - border-radius: 5px; - width: 100%; + .clearable-input-group { + flex-grow: 1; } .clearable-text-field { From 8f3188ff743d2f02e1900a3715ce2c70d120f126 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:54:44 +1100 Subject: [PATCH 063/152] Make gallery/scene association during scan more consistent (#6705) --- internal/manager/task_scan.go | 12 ++++---- pkg/gallery/scan.go | 1 - pkg/image/scan.go | 39 ++++++++++++++++++++++-- pkg/models/mocks/GalleryReaderWriter.go | 14 +++++++++ pkg/models/repository_gallery.go | 1 + pkg/scene/scan.go | 40 ++++++++++++++++++++++++- pkg/sqlite/gallery.go | 4 +++ 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 53e6944b5..22849124c 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -660,8 +660,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: r.Image, - GalleryFinder: r.Gallery, + CreatorUpdater: r.Image, + GalleryFinder: r.Gallery, + SceneFinderUpdater: r.Scene, ScanGenerator: &imageGenerators{ input: options, taskQueue: taskQueue, @@ -690,9 +691,10 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(videoFileFilter), Handler: &scene.ScanHandler{ - CreatorUpdater: r.Scene, - CaptionUpdater: r.File, - PluginCache: pluginCache, + CreatorUpdater: r.Scene, + GalleryFinderUpdater: r.Gallery, + CaptionUpdater: r.File, + PluginCache: pluginCache, ScanGenerator: &sceneGenerators{ input: options, taskQueue: taskQueue, diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index b3e5d2c3c..7689bb9b6 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -24,7 +24,6 @@ type ScanCreatorUpdater interface { type ScanSceneFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Scene, error) - Update(ctx context.Context, updatedScene *models.Scene) error AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 99b31f698..682641e66 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -39,6 +40,11 @@ type GalleryFinderCreator interface { UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } +type ScanSceneFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Scene, error) + AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error +} + type ScanConfig interface { GetCreateGalleriesFromFolders() bool } @@ -48,8 +54,9 @@ type ScanGenerator interface { } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater - GalleryFinder GalleryFinderCreator + CreatorUpdater ScanCreatorUpdater + GalleryFinder GalleryFinderCreator + SceneFinderUpdater ScanSceneFinderUpdater ScanGenerator ScanGenerator @@ -322,11 +329,39 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo return nil, fmt.Errorf("creating zip-based gallery: %w", err) } + // try to associate with scene + if err := h.associateScene(ctx, &newGallery, zipFile); err != nil { + return nil, fmt.Errorf("associating scene: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) return &newGallery, nil } +func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error { + galleryIDs := []int{existing.ID} + + path := zipFile.Base().Path + withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*" + + // find scenes with a file that matches + scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt) + if err != nil { + return err + } + + for _, scene := range scenes { + // found related Scene + logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID) + if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil { + return err + } + } + + return nil +} + func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) { // don't create folder-based galleries for files in zip file if f.Base().ZipFile != nil { diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f20d9f76e..e835ea2bc 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -49,6 +49,20 @@ func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, ima return r0 } +// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs +func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + ret := _m.Called(ctx, galleryID, sceneIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { + r0 = rf(ctx, galleryID, sceneIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // All provides a mock function with given fields: ctx func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) { ret := _m.Called(ctx) diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index b8f1452f3..8fc3b29d5 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -83,6 +83,7 @@ type GalleryWriter interface { CustomFieldsWriter + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index c70c44a9e..c9cc2c567 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "path/filepath" + "strings" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/logger" @@ -32,12 +34,18 @@ type ScanCreatorUpdater interface { AddFileID(ctx context.Context, id int, fileID models.FileID) error } +type ScanGalleryFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error +} + type ScanGenerator interface { Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater + CreatorUpdater ScanCreatorUpdater + GalleryFinderUpdater ScanGalleryFinderUpdater ScanGenerator ScanGenerator CaptionUpdater video.CaptionUpdater @@ -127,6 +135,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. } } + if err := h.associateGallery(ctx, existing, f); err != nil { + return err + } + // do this after the commit so that cover generation doesn't hold up the transaction txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { @@ -175,3 +187,29 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return nil } + +func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error { + sceneIDs := make([]int, len(existing)) + for i, s := range existing { + sceneIDs[i] = s.ID + } + + path := f.Base().Path + zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip" + + // find galleries with a file that matches + galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath) + if err != nil { + return err + } + + for _, gallery := range galleries { + // found related Scene + logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID) + if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 305b1fe09..ad7a94b04 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -926,3 +926,7 @@ func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } + +func (qb *GalleryStore) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + return galleriesScenesTableMgr.insertJoins(ctx, galleryID, sceneIDs) +} From 4167224107e8749347601854b5c8da953a0ae0f0 Mon Sep 17 00:00:00 2001 From: Stash-KennyG <138793998+Stash-KennyG@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:03:36 -0400 Subject: [PATCH 064/152] Feature: Add StashID guid consideration into select boxes (#6709) * Add GUID search for performers in PerformerSelect component * Refactor and apply to all objects with stash ids --------- Co-authored-by: KennyG Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../components/Performers/PerformerSelect.tsx | 26 ++++++++--- ui/v2.5/src/components/Scenes/SceneSelect.tsx | 44 +++++++++++++------ .../src/components/Shared/FilterSelect.tsx | 7 +++ .../src/components/Studios/StudioSelect.tsx | 41 ++++++++++++----- ui/v2.5/src/components/Tags/TagSelect.tsx | 41 ++++++++++++----- ui/v2.5/src/models/list-filter/utils.ts | 12 +++++ ui/v2.5/src/utils/stashIds.ts | 6 ++- 7 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 ui/v2.5/src/models/list-filter/utils.ts diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index f10519897..133ffd854 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -23,6 +23,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; @@ -32,6 +33,8 @@ import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { PerformerPopover } from "./PerformerPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -91,19 +94,32 @@ const _PerformerSelect: React.FC< async function loadPerformers(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Performers); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; + + // If the input looks like a GUID, search for stash_id first and return match immediately + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindPerformersForSelect(filter); + const matches = query.data.findPerformers.performers.slice(); + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + const query = await queryFindPerformersForSelect(filter); return performerSelectSort( input, query.data.findPerformers.performers.slice() - ).map((performer) => ({ - value: performer.id, - object: performer, - })); + ).map(toOption); } const PerformerOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Scenes/SceneSelect.tsx b/ui/v2.5/src/components/Scenes/SceneSelect.tsx index 8ab32b753..fed72dd53 100644 --- a/ui/v2.5/src/components/Scenes/SceneSelect.tsx +++ b/ui/v2.5/src/components/Scenes/SceneSelect.tsx @@ -22,6 +22,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; @@ -33,6 +34,8 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { TruncatedText } from "../Shared/TruncatedText"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type Scene = Pick & { studio?: Pick | null; @@ -73,29 +76,44 @@ const _SceneSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(scene: Scene) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(scene.id.toString()); + } + async function loadScenes(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Scenes); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - if (props.extraCriteria) { - filter.criteria = [...props.extraCriteria]; + filter.criteria = [...(props.extraCriteria ?? [])]; + + if (isUUID(input)) { + const oldCriteria = filter.criteria; + + filterByStashID(filter, input); + + const query = await queryFindScenesForSelect(filter); + const matches = query.data.findScenes.scenes.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = oldCriteria; // Clear stash_id criterion to search by name/alias below. } - const query = await queryFindScenesForSelect(filter); - let ret = query.data.findScenes.scenes.filter((scene) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(scene.id.toString()); - }); + filter.searchTerm = input; - return sceneSelectSort(input, ret).map((scene) => ({ - value: scene.id, - object: scene, - })); + const query = await queryFindScenesForSelect(filter); + const ret = query.data.findScenes.scenes.filter(filterExcluded); + + return sceneSelectSort(input, ret).map(toOption); } const SceneOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index e1c117aac..fbe786522 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -256,3 +256,10 @@ export interface IFilterIDProps { ids?: string[]; onSelect?: (item: T[]) => void; } + +export function toOption(item: T): Option { + return { + value: item.id, + object: item, + }; +} diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 7305aa60d..b80834c84 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -23,11 +23,14 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -74,24 +77,40 @@ const _StudioSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(studio: Studio) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(studio.id.toString()); + } + async function loadStudios(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Studios); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindStudiosForSelect(filter); - let ret = query.data.findStudios.studios.filter((studio) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(studio.id.toString()); - }); - return studioSelectSort(input, ret).map((studio) => ({ - value: studio.id, - object: studio, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindStudiosForSelect(filter); + const matches = query.data.findStudios.studios.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindStudiosForSelect(filter); + const ret = query.data.findStudios.studios.filter(filterExcluded); + + return studioSelectSort(input, ret).map(toOption); } const StudioOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index c9ed83fea..b79915261 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -23,12 +23,15 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { TagPopover } from "./TagPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -75,24 +78,40 @@ const _TagSelect: React.FC = (props) => { const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(tag: Tag) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(tag.id.toString()); + } + async function loadTags(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Tags); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindTagsForSelect(filter); - let ret = query.data.findTags.tags.filter((tag) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(tag.id.toString()); - }); - return tagSelectSort(input, ret).map((tag) => ({ - value: tag.id, - object: tag, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindTagsForSelect(filter); + const matches = query.data.findTags.tags.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindTagsForSelect(filter); + const ret = query.data.findTags.tags.filter(filterExcluded); + + return tagSelectSort(input, ret).map(toOption); } const TagOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/models/list-filter/utils.ts b/ui/v2.5/src/models/list-filter/utils.ts new file mode 100644 index 000000000..5c63b1214 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/utils.ts @@ -0,0 +1,12 @@ +import { CriterionModifier } from "src/core/generated-graphql"; +import { ModifierCriterion } from "./criteria/criterion"; +import { ListFilterModel } from "./filter"; + +export function filterByStashID(filter: ListFilterModel, stashID: string) { + const stashCriterion = filter.makeCriterion( + "stash_id_endpoint" + ) as ModifierCriterion<{ endpoint: string; stashID: string }>; + stashCriterion.modifier = CriterionModifier.Equals; + stashCriterion.value = { endpoint: "", stashID: stashID.trim() }; + filter.criteria = [stashCriterion]; +} diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index 10e3835b8..635db3600 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -13,6 +13,10 @@ export const getStashIDs = ( const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export function isUUID(input: string): boolean { + return UUID_PATTERN.test(input.trim()); +} + /** * Separates a list of inputs into names and StashIDs based on UUID pattern matching * @param inputs - Array of strings that could be either names or StashIDs @@ -25,7 +29,7 @@ export const separateNamesAndStashIds = ( const stashIds: string[] = []; inputs.forEach((input) => { - if (UUID_PATTERN.test(input)) { + if (isUUID(input)) { stashIds.push(input); } else { names.push(input); From c583e88caf026fe619c2cca888b3aa1e34eec380 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:10:42 +1100 Subject: [PATCH 065/152] Replace "Source" with "Combined" in merge dialogs (#6711) --- ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx | 2 +- ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx | 2 +- ui/v2.5/src/components/Tags/TagMergeDialog.tsx | 2 +- ui/v2.5/src/locales/en-GB.json | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index 0d42dd6ed..ce5b50b0c 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -700,7 +700,7 @@ const PerformerMergeDetails: React.FC = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( Date: Thu, 19 Mar 2026 13:16:20 +1100 Subject: [PATCH 066/152] Make hover volume configurable (#6712) --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 8 ++- .../src/components/Scenes/SceneWallPanel.tsx | 59 ++++++++++++------- .../SettingsInterfacePanel.tsx | 35 ++++++++--- ui/v2.5/src/core/config.ts | 3 + ui/v2.5/src/locales/en-GB.json | 11 +++- 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 0a80880f1..55124e9b0 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -30,12 +30,14 @@ import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; import { FileSize } from "../Shared/FileSize"; import { OCounterButton } from "../Shared/CountButton"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + volume?: number; vttPath?: string; onScrubberClick?: (timestamp: number) => void; disabled?: boolean; @@ -49,6 +51,7 @@ export const ScenePreview: React.FC = ({ vttPath, onScrubberClick, disabled, + volume, }) => { const videoEl = useRef(null); @@ -67,8 +70,8 @@ export const ScenePreview: React.FC = ({ useEffect(() => { if (videoEl?.current?.volume) - videoEl.current.volume = soundActive ? 0.05 : 0; - }, [soundActive]); + videoEl.current.volume = soundActive ? (volume ?? 0) / 100 : 0; + }, [volume, soundActive]); return (
    @@ -431,6 +434,7 @@ const SceneCardImage = PatchComponent( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + volume={configuration?.ui.previewVolume ?? defaultPreviewVolume} vttPath={props.scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} disabled={props.selecting} diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index d960db31f..d49d9b73e 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; @@ -15,6 +21,7 @@ import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePhoto { scene: GQL.SlimSceneDataFragment; @@ -42,6 +49,7 @@ export const SceneWallItem: React.FC< const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; + const volume = configuration?.ui.previewVolume ?? defaultPreviewVolume; const showTitle = configuration?.interface.wallShowTitle ?? false; const height = Math.min(props.maxHeight, props.photo.height); @@ -75,7 +83,31 @@ export const SceneWallItem: React.FC< }; const video = props.photo.src.includes("preview"); - const ImagePreview = video ? "video" : "img"; + const previewProps = { + loading: "lazy", + loop: video, + muted: !video || !playSound || !active, + autoPlay: video, + playsInline: video, + key: props.photo.key, + src: props.photo.src, + width, + height, + alt: props.photo.alt, + onMouseEnter: () => setActive(true), + onMouseLeave: () => setActive(false), + onClick: handleClick, + onError: () => { + props.photo.onError?.(props.photo); + }, + }; + + const videoEl = useRef(null); + + useEffect(() => { + if (video && videoEl?.current?.volume) + videoEl.current.volume = playSound ? volume / 100 : 0; + }, [video, playSound, volume]); const { scene } = props.photo; const title = objectTitle(scene); @@ -111,24 +143,11 @@ export const SceneWallItem: React.FC< }} /> )} - setActive(true)} - onMouseLeave={() => setActive(false)} - onClick={handleClick} - onError={() => { - props.photo.onError?.(props.photo); - }} - /> + {video ? ( +
    @@ -125,3 +83,23 @@ const TaggerConfig: React.FC = ({ }; export default TaggerConfig; + +export const ConfigButton: React.FC<{ + onClick: () => void; + showConfig: boolean; +}> = ({ onClick, showConfig }) => { + const intl = useIntl(); + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 8106d6a44..4391ba783 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -15,11 +15,10 @@ import { evictQueries, performerMutationImpactedQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; @@ -28,6 +27,7 @@ import { mergeStashIDs } from "src/utils/stashbox"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { useTaggerConfig } from "../config"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -620,11 +620,9 @@ interface ITaggerProps { export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -742,76 +740,80 @@ export const PerformerTagger: React.FC = ({ performers }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - setConfig({ ...config, excludedPerformerFields: fields }) - } - fields={PERFORMER_FIELDS} - entityName="performers" - /> - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" + /> + + + ); diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 76a67e306..a0ee46733 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -4,7 +4,6 @@ import { SceneQueue } from "src/models/sceneQueue"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { OperationButton } from "src/components/Shared/OperationButton"; import { ISceneQueryResult, TaggerStateContext } from "../context"; @@ -13,8 +12,8 @@ import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; import { SceneSearchResults } from "./StashSearchResult"; import { useConfigurationContext } from "src/hooks/Config"; -import { faCog } from "@fortawesome/free-solid-svg-icons"; import { useLightbox } from "src/hooks/Lightbox/hooks"; +import { ConfigButton } from "../TaggerConfig"; const Scene: React.FC<{ scene: GQL.SlimSceneDataFragment; @@ -154,16 +153,6 @@ export const Tagger: React.FC = ({ ); } - function renderConfigButton() { - return ( -
    - -
    - ); - } - const [spriteImage, setSpriteImage] = useState(null); const lightboxImage = useMemo( () => [{ paths: { thumbnail: spriteImage, image: spriteImage } }], @@ -293,7 +282,12 @@ export const Tagger: React.FC = ({ {maybeRenderShowHideUnmatchedButton()} {maybeRenderSubmitFingerprintsButton()} {renderFragmentScrapeButton()} - {renderConfigButton()} +
    + setShowConfig(!showConfig)} + /> +
    diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index adc58cc04..645fb19c2 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -15,11 +15,10 @@ import { useStudioCreate, evictQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; @@ -33,6 +32,7 @@ import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -471,11 +471,9 @@ interface ITaggerProps { export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -598,98 +596,102 @@ export const StudioTagger: React.FC = ({ studios }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - setConfig({ ...config, excludedStudioFields: fields }) - } - fields={STUDIO_FIELDS} - entityName="studios" - extraConfig={ - - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - } - /> - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } + /> + + + ); diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 21891724c..8b22a5920 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -12,11 +12,10 @@ import { mutateStashBoxBatchTagTag, getClient, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; import { ExternalLink } from "src/components/Shared/ExternalLink"; @@ -27,6 +26,7 @@ import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -414,11 +414,9 @@ interface ITaggerProps { export const TagTagger: React.FC = ({ tags }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -533,98 +531,102 @@ export const TagTagger: React.FC = ({ tags }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - setConfig({ ...config, excludedTagFields: fields }) - } - fields={TAG_FIELDS} - entityName="tags" - extraConfig={ - - - } - checked={config.createParentTags} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentTags: e.currentTarget.checked, - }) - } - /> - - - - - } - /> - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + extraConfig={ + + + } + checked={config.createParentTags} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentTags: e.currentTarget.checked, + }) + } + /> + + + + + } + /> + + + ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 46de0e8b7..048a7d7d0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1404,6 +1404,7 @@ "rating": "Rating", "recently_added_objects": "Recently Added {objects}", "recently_released_objects": "Recently Released {objects}", + "refer_to": "Please see {link}.", "release_notes": "Release Notes", "resolution": "Resolution", "resume_time": "Resume Time", From 79b6cb6fd28ee28d463696bd1a330aa841cfeea2 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Wed, 18 Mar 2026 22:36:58 -0400 Subject: [PATCH 068/152] Lint + build update and retooling (#6638) * update compiler and build process - assemble cross-builds in multi-build steps - clean up unnecessary dependences - use node docker image instead of nodesource (unsupported) - downgrade to freebsd12 to match compiler Co-authored-by: Gykes * [compiler] use new image instead of placeholder removes .gitignore, update README * [CI] lock pnpm action-setup to SHA hash * bump @actions/upload-artifact --------- Co-authored-by: feederbox826 Co-authored-by: Gykes Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .github/workflows/build-compiler.yml | 28 +++ .github/workflows/build.yml | 257 +++++++++++++++++++-------- .github/workflows/golangci-lint.yml | 61 +------ Makefile | 2 +- docker/compiler/.gitignore | 1 - docker/compiler/Dockerfile | 138 +++++++------- docker/compiler/Makefile | 20 ++- docker/compiler/README.md | 2 +- docs/DEVELOPMENT.md | 4 +- ui/v2.5/package.json | 1 + 10 files changed, 310 insertions(+), 204 deletions(-) create mode 100644 .github/workflows/build-compiler.yml delete mode 100644 docker/compiler/.gitignore diff --git a/.github/workflows/build-compiler.yml b/.github/workflows/build-compiler.yml new file mode 100644 index 000000000..e7881720b --- /dev/null +++ b/.github/workflows/build-compiler.yml @@ -0,0 +1,28 @@ +name: Compiler Build + +on: + workflow_dispatch: + +env: + COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 + +jobs: + build-compiler: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + push: true + context: "{{defaultContext}}:docker/compiler" + tags: | + ${{ env.COMPILER_IMAGE }} + ghcr.io/stashapp/compiler:latest + cache-from: type=gha,scope=all,mode=max + cache-to: type=gha,scope=all,mode=max \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e46ecd69..1dcde9f83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build on: push: - branches: + branches: - develop - master - 'releases/**' @@ -15,50 +15,160 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:12 + COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 jobs: - build: - runs-on: ubuntu-22.04 + # Job 1: Generate code and build UI + # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers. + # Produces artifacts (generated Go files + UI build) consumed by test and build jobs. + generate: + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 - - name: Checkout - run: git fetch --prune --unshallow --tags + # pnpm version is read from the packageManager field in package.json + # very broken (4.3, 4.4) + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + with: + package_json_file: ui/v2.5/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: ui/v2.5/pnpm-lock.yaml + + - name: Install UI dependencies + run: cd ui/v2.5 && pnpm install --frozen-lockfile + + - name: Generate + run: make generate + + - name: Cache UI build + uses: actions/cache@v5 + id: cache-ui + with: + path: ui/v2.5/build + key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} + + - name: Validate UI + # skip UI validation for pull requests if UI is unchanged + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make validate-ui + + - name: Build UI + # skip UI build for pull requests if UI is unchanged (UI was cached) + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make ui + + # Bundle generated Go files + UI build for downstream jobs (test + build) + - name: Upload generated artifacts + uses: actions/upload-artifact@v7 + with: + name: generated + retention-days: 1 + path: | + internal/api/generated_exec.go + internal/api/generated_models.go + ui/v2.5/build/ + ui/login/locales/ + + # Job 2: Integration tests + # Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04. + # Runs in parallel with the build matrix jobs. + test: + needs: generate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Cache node modules - uses: actions/cache@v3 - env: - cache-name: cache-node_modules + # Places generated Go files + UI build into the working tree so the build compiles + - name: Download generated artifacts + uses: actions/download-artifact@v8 with: - path: ui/v2.5/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }} + name: generated - - name: Cache UI build - uses: actions/cache@v3 - id: cache-ui - env: - cache-name: cache-ui + - name: Test Backend + run: make it + + # Job 3: Cross-compile for all platforms + # Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13). + # Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC), + # so running them in separate containers is functionally identical to one container. + # Runs in parallel with the test job. + build: + needs: generate + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - platform: windows + make-target: build-cc-windows + artifact-paths: | + dist/stash-win.exe + tag: win + - platform: macos + make-target: build-cc-macos + artifact-paths: | + dist/stash-macos + dist/Stash.app.zip + tag: osx + - platform: linux + make-target: build-cc-linux + artifact-paths: | + dist/stash-linux + tag: linux + - platform: linux-arm64v8 + make-target: build-cc-linux-arm64v8 + artifact-paths: | + dist/stash-linux-arm64v8 + tag: arm + - platform: linux-arm32v7 + make-target: build-cc-linux-arm32v7 + artifact-paths: | + dist/stash-linux-arm32v7 + tag: arm + - platform: linux-arm32v6 + make-target: build-cc-linux-arm32v6 + artifact-paths: | + dist/stash-linux-arm32v6 + tag: arm + - platform: freebsd + make-target: build-cc-freebsd + artifact-paths: | + dist/stash-freebsd + tag: freebsd + + steps: + - uses: actions/checkout@v6 with: - path: ui/v2.5/build - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} + fetch-depth: 1 + fetch-tags: true - - name: Cache go build - uses: actions/cache@v3 - env: - # increment the number suffix to bump the cache - cache-name: cache-go-cache-1 + - name: Download generated artifacts + uses: actions/download-artifact@v8 + with: + name: generated + + - name: Cache Go build + uses: actions/cache@v5 with: path: .go-cache - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('go.mod', '**/go.sum') }} + key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }} + + # kept seperate to test timings + - name: pull compiler image + run: docker pull $COMPILER_IMAGE - name: Start build container env: @@ -67,45 +177,48 @@ jobs: mkdir -p .go-cache docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null - - name: Pre-install - run: docker exec -t build /bin/bash -c "make CI=1 pre-ui" - - - name: Generate - run: docker exec -t build /bin/bash -c "make generate" - - - name: Validate UI - # skip UI validation for pull requests if UI is unchanged - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make validate-ui" - - # Static validation happens in the linter workflow in parallel to this workflow - # Run Dynamic validation here, to make sure we pass all the projects integration tests - - name: Test Backend - run: docker exec -t build /bin/bash -c "make it" - - - name: Build UI - # skip UI build for pull requests if UI is unchanged (UI was cached) - # this means that the build version/time may be incorrect if the UI is - # not changed in a pull request - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make ui" - - - name: Compile for all supported platforms - run: | - docker exec -t build /bin/bash -c "make build-cc-windows" - docker exec -t build /bin/bash -c "make build-cc-macos" - docker exec -t build /bin/bash -c "make build-cc-linux" - docker exec -t build /bin/bash -c "make build-cc-linux-arm64v8" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v7" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6" - docker exec -t build /bin/bash -c "make build-cc-freebsd" - - - name: Zip UI - run: docker exec -t build /bin/bash -c "make zip-ui" + - name: Build (${{ matrix.platform }}) + run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}" - name: Cleanup build container run: docker rm -f -v build + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ matrix.platform }} + retention-days: 1 + path: ${{ matrix.artifact-paths }} + + # Job 4: Release + # Waits for both test and build to pass, then collects all platform artifacts + # into dist/ for checksums, GitHub releases, and multi-arch Docker push. + release: + needs: [test, build] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories + - name: Download all build artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + # Reassemble platform binaries from matrix job artifacts into a single dist/ directory + # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root + - name: Collect binaries + run: | + mkdir -p dist + cp artifacts/build-*/* dist/ + + - name: Zip UI + run: | + cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip . + - name: Generate checksums run: | git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1 @@ -116,7 +229,7 @@ jobs: - name: Upload Windows binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-win.exe path: dist/stash-win.exe @@ -124,7 +237,7 @@ jobs: - name: Upload macOS binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-macos path: dist/stash-macos @@ -132,7 +245,7 @@ jobs: - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-linux path: dist/stash-linux @@ -140,14 +253,14 @@ jobs: - name: Upload UI # only upload for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-ui.zip path: dist/stash-ui.zip - name: Update latest_develop tag if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} - run : git tag -f latest_develop; git push -f --tags + run: git tag -f latest_develop; git push -f --tags - name: Development Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} @@ -197,7 +310,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap @@ -213,7 +326,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 71c743ced..19a6d62bd 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,65 +9,20 @@ on: - 'releases/**' pull_request: -env: - COMPILER_IMAGE: stashapp/compiler:12 - jobs: golangci: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Checkout - run: git fetch --prune --unshallow --tags - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Start build container - run: | - mkdir -p .go-cache - docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null + # no tags or depth needed for lint + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + # generate-backend runs natively (just go generate + touch-ui) — no Docker needed - name: Generate Backend - run: docker exec -t build /bin/bash -c "make generate-backend" + run: make generate-backend + ## WARN + ## using v1, update in a later PR - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # - # Note: By default, the `.golangci.yml` file should be at the root of the repository. - # The location of the configuration file can be changed by using `--config=` - args: --timeout=5m - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true, then all caching functionality will be completely disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. - # install-mode: "goinstall" - - - name: Cleanup build container - run: docker rm -f -v build + uses: golangci/golangci-lint-action@v6 \ No newline at end of file diff --git a/Makefile b/Makefile index 7e19063a3..4f8d9cadd 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ export CGO_ENABLED := 1 # define COMPILER_IMAGE for cross-compilation docker container ifndef COMPILER_IMAGE - COMPILER_IMAGE := stashapp/compiler:latest + COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest endif .PHONY: release diff --git a/docker/compiler/.gitignore b/docker/compiler/.gitignore deleted file mode 100644 index 7012bfd63..000000000 --- a/docker/compiler/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sdk.tar.* \ No newline at end of file diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index 0154d7e61..c9dfb9c7c 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,82 +1,86 @@ -FROM golang:1.24.3 +### OSXCROSS +FROM debian:bookworm AS osxcross +# add osxcross +WORKDIR /tmp/osxcross +ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b +ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz -LABEL maintainer="https://discord.gg/2TsNFKt" +ARG OSX_SDK_VERSION=11.3 +ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz +ARG OSX_SDK_DOWNLOAD_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} +ADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE} -RUN apt-get update && apt-get install -y apt-transport-https ca-certificates gnupg +ENV UNATTENDED=yes \ + SDK_VERSION=${OSX_SDK_VERSION} \ + OSX_VERSION_MIN=10.10 +RUN apt update && \ + apt install -y --no-install-recommends \ + bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev +# lzma-dev libxml2-dev xz +RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz +RUN ./build.sh -RUN mkdir -p /etc/apt/keyrings +### FREEBSD cross-compilation stage +# use alpine for cacheable image since apt is notorous for not caching +FROM alpine:3 AS freebsd +# match golang latest +# https://go.dev/wiki/FreeBSD +ARG FREEBSD_VERSION=12.4 +ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \ + http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \ + /tmp/base.txz -ADD https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key nodesource.gpg.key -RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key -RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +WORKDIR /opt/cross-freebsd +RUN apk add --no-cache tar xz +RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib +RUN cd /opt/cross-freebsd/usr/lib && \ + find . -type l -exec sh -c ' \ + for link; do \ + target=$(readlink "$link"); \ + case "$target" in \ + /lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \ + esac; \ + done \ + ' sh {} + && \ + ln -s libc++.a libstdc++.a && \ + ln -s libc++.so libstdc++.so -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git make tar bash nodejs zip \ - clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \ - bzip2 gzip sed cpio libbz2-dev zlib1g-dev \ - gcc-mingw-w64 \ - gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \ - gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ - rm -rf /var/lib/apt/lists/*; +### BUILDER +FROM golang:1.24.3 AS builder +ENV PATH=/opt/osx-ndk-x86/bin:$PATH + +# copy in nodejs instead of using nodesource :thumbsup: +COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local +# copy in osxcross +COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib +COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86 +# copy in cross-freebsd +COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd # pnpm install with npm RUN npm install -g pnpm -# FreeBSD cross-compilation setup -# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66 -ENV FREEBSD_VERSION 13.4 -ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz -ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c +# git for getting hash +# make and bash for building -RUN cd /tmp && \ - curl -o base.txz $FREEBSD_DOWNLOAD_URL && \ - echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \ - mkdir -p /opt/cross-freebsd && \ - cd /opt/cross-freebsd && \ - tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \ - rm -f /tmp/base.txz && \ - cd /opt/cross-freebsd/usr/lib && \ - find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \ - ln -s libc++.a libstdc++.a && \ - ln -s libc++.so libstdc++.so - -# macOS cross-compilation setup -ENV OSX_SDK_VERSION 11.3 -ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz -ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} -ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 -ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b -ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} -ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 - -RUN cd /tmp && \ - curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \ - echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \ - mkdir osxcross && \ - tar --strip=1 -C osxcross -xf osxcross.tar.gz && \ - rm -f osxcross.tar.gz && \ - curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \ - echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \ - mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \ - UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \ - cp osxcross/target/lib/* /usr/lib/ && \ - mv osxcross/target /opt/osx-ndk-x86 && \ - rm -rf /tmp/osxcross - -ENV PATH /opt/osx-ndk-x86/bin:$PATH - -RUN mkdir -p /root/.ssh && \ - chmod 0700 /root/.ssh && \ - ssh-keyscan github.com > /root/.ssh/known_hosts - -# ignore "dubious ownership" errors +# clang for macos +# zip for stashapp.zip +# gcc-extensions for cross-arch build +# we still target arm soft float? +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git make bash \ + clang zip \ + gcc-mingw-w64 \ + gcc-arm-linux-gnueabi \ + libc-dev-armel-cross linux-libc-dev-armel-cross \ + gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ + rm -rf /var/lib/apt/lists/*; RUN git config --global safe.directory '*' - # To test locally: # make generate # make ui # cd docker/compiler -# make build -# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all -# # binaries will show up in /dist +# docker build . -t ghcr.io/stashapp/compiler:latest +# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all +# # binaries will show up in /dist \ No newline at end of file diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index ed6a9a285..66f19f5d6 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,16 +1,22 @@ +host=ghcr.io user=stashapp repo=compiler -version=12 +version=13 + +VERSION_IMAGE = ${host}/${user}/${repo}:${version} +LATEST_IMAGE = ${host}/${user}/${repo}:latest latest: - docker build -t ${user}/${repo}:latest . + docker build -t ${LATEST_IMAGE} . build: - docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . build-no-cache: - docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . -install: build - docker push ${user}/${repo}:${version} - docker push ${user}/${repo}:latest +# requires docker login ghcr.io +# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin +push: + docker push ${VERSION_IMAGE} + docker push ${LATEST_IMAGE} \ No newline at end of file diff --git a/docker/compiler/README.md b/docker/compiler/README.md index 6bb7d8d99..c7b4840f9 100644 --- a/docker/compiler/README.md +++ b/docker/compiler/README.md @@ -1,3 +1,3 @@ Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser -When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag. +When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag. \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 85c2f6f23..a26ce6817 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,8 +118,8 @@ This project uses a modification of the [CI-GoReleaser](https://github.com/bep/d To cross-compile the app yourself: 1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI. -2. Pull the latest compiler image from Docker Hub: `docker pull stashapp/compiler` -3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it stashapp/compiler /bin/bash` to open a shell inside the container. +2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler` +3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container. 4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets). 5. You will find the compiled binaries in `dist/`. diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index e024a0053..001e7fb60 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -3,6 +3,7 @@ "private": true, "homepage": "./", "type": "module", + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "scripts": { "start": "vite", "build": "vite build", From 58cf6307cb28cebfb667711a871f72615ac1f157 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:59:42 +1100 Subject: [PATCH 069/152] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0310.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md index af7ed159b..afb618507 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0310.md +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -37,6 +37,8 @@ * Added button to delete scene cover. ([#6444](https://github.com/stashapp/stash/pull/6444)) * Duplicate aliases are now silently removed. ([#6514](https://github.com/stashapp/stash/pull/6514)) * Image query now includes image details field. ([#6673](https://github.com/stashapp/stash/pull/6673)) +* Select scene/performer/studio/tag dropdowns now accept stash-ids as input. ([#6709](https://github.com/stashapp/stash/pull/6709)) +* Volume when hovering over a scene preview is now configurable. ([#6712](https://github.com/stashapp/stash/pull/6712)) * Added non-binary gender icon. ([#6489](https://github.com/stashapp/stash/pull/6489)) * Transgender icons are now coloured by their presented gender. ([#6489](https://github.com/stashapp/stash/pull/6489)) * It is now possible to add a library path to a non-existing directory (useful for disconnected network paths). ([#6644](https://github.com/stashapp/stash/pull/6644)) @@ -47,9 +49,11 @@ * Added support for sorting performers, studios and tags by total scene file size. ([#6642](https://github.com/stashapp/stash/pull/6642)) * Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) * Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) +* Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) * Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443)) * Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447)) * Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478)) +* Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704)) * Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701)) * Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448)) @@ -62,6 +66,7 @@ * Improved scanning algorithm to prevent creation of orphaned folders and handle missing parent folders. ([#6608](https://github.com/stashapp/stash/pull/6608)) * Scanning no longer scans zip contents when the zip file is unchanged. ([#6633](https://github.com/stashapp/stash/pull/6633)) * Captions are now correctly detected in a single scan. ([#6634](https://github.com/stashapp/stash/pull/6634)) +* Fixed galleries not being linked to scenes when scanning a matching file. ([#6705](https://github.com/stashapp/stash/pull/6705)) * Fixed mis-clicks on cards navigating to new page when selecting items. ([#6599](https://github.com/stashapp/stash/pull/6599), [#6649](https://github.com/stashapp/stash/pull/6649)) * Select dropdown now retains focus after creating a new option. ([#6697](https://github.com/stashapp/stash/pull/6697)) * Fixed custom field filtering not working correctly when query value was provided. ([#6614](https://github.com/stashapp/stash/pull/6614)) From 640d62cf59868951109c6a54207d8171a44b873f Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Thu, 19 Mar 2026 00:10:04 -0400 Subject: [PATCH 070/152] [CI] ensure artifacts have +x bit set (#6715) --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dcde9f83..556df6be4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -209,11 +209,13 @@ jobs: path: artifacts # Reassemble platform binaries from matrix job artifacts into a single dist/ directory + # make sure that artifacts have executable bit set # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root - name: Collect binaries run: | mkdir -p dist cp artifacts/build-*/* dist/ + chmod +x dist/* - name: Zip UI run: | From ee9a852ec9ec864c2d12f2f139961f63ff207078 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:35:12 +1100 Subject: [PATCH 071/152] Remove phasher from build target [skip ci] --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4f8d9cadd..5a56df3ea 100644 --- a/Makefile +++ b/Makefile @@ -129,7 +129,7 @@ phasher: build-flags # builds dynamically-linked debug binaries .PHONY: build -build: stash phasher +build: stash # builds dynamically-linked PIE release binaries .PHONY: build-release From c832e1a8a29250cd2720bac3b3b042b0b62aee49 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Thu, 19 Mar 2026 03:31:14 -0400 Subject: [PATCH 072/152] remove phasher target from bundle (#6717) [skip ci] --- Makefile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5a56df3ea..d9caf0ee5 100644 --- a/Makefile +++ b/Makefile @@ -187,8 +187,6 @@ build-cc-macos: # Combine into universal binaries lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm - lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm - rm dist/phasher-macos-intel dist/phasher-macos-arm # Place into bundle and zip up rm -rf dist/Stash.app @@ -198,6 +196,16 @@ build-cc-macos: cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app rm -rf dist/Stash.app +.PHONY: build-cc-macos-phasher +build-cc-macos-phasher: + make build-cc-macos-arm + make build-cc-macos-intel + + # Combine into universal binaries + lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm + rm dist/phasher-macos-intel dist/phasher-macos-arm + # do not bundle phasher + .PHONY: build-cc-freebsd build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOARCH := amd64 From 865c50d615c0c7b60509f9fa0345a544c3c601f9 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:02:38 -0400 Subject: [PATCH 073/152] [ui] Fix Tag Modal cutting off (#6734) --- ui/v2.5/src/components/Tagger/tags/TagModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx index d9a7d99b8..804e5b55c 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -103,7 +103,7 @@ const TagModal: React.FC = ({ : - + ); } @@ -147,7 +147,7 @@ const TagModal: React.FC = ({ : - + ); } From 7a18b5310b6fdb408b2d183be4c3264a277720c0 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:06:20 +0200 Subject: [PATCH 074/152] Add GitHub Sponsors and forum links to about section (#6718) * Add GitHub sponsors link to about section * Add forum link to about section * Fix casing in 'latest_version_build_hash' string in localization file --- .../Settings/SettingsAboutPanel.tsx | 20 ++++++++++++++----- ui/v2.5/src/locales/en-GB.json | 10 +++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx index 9d9922330..316f471be 100644 --- a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx @@ -129,7 +129,7 @@ export const SettingsAboutPanel: React.FC = () => { { url: ( - Documentation + documentation ), } @@ -137,9 +137,14 @@ export const SettingsAboutPanel: React.FC = () => {

    {intl.formatMessage( - { id: "config.about.stash_discord" }, + { id: "config.about.stash_community" }, { - url: ( + forumUrl: ( + + forum + + ), + discordUrl: ( Discord @@ -149,13 +154,18 @@ export const SettingsAboutPanel: React.FC = () => {

    {intl.formatMessage( - { id: "config.about.stash_open_collective" }, + { id: "config.about.support_us" }, { - url: ( + openCollectiveUrl: ( Open Collective ), + githubSponsorsUrl: ( + + GitHub Sponsors + + ), } )}

    diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 048a7d7d0..e27d51310 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -251,13 +251,13 @@ "build_time": "Build time:", "check_for_new_version": "Check for new version", "latest_version": "Latest Version", - "latest_version_build_hash": "Latest Version Build Hash:", + "latest_version_build_hash": "Latest version build hash:", "new_version_notice": "[NEW]", "release_date": "Release date:", - "stash_discord": "Join our {url} channel", - "stash_home": "Stash home at {url}", - "stash_open_collective": "Support us through {url}", - "stash_wiki": "Stash {url} page", + "stash_home": "Visit Stash on {url}.", + "stash_wiki": "Check out Stash {url} website.", + "stash_community": "Join our {forumUrl} or {discordUrl} community server.", + "support_us": "Support us through {openCollectiveUrl} or {githubSponsorsUrl}.", "version": "Version" }, "advanced_mode": "Advanced mode", From b11be4807ad11bcc23db35188c703af7714521de Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:07:13 -0400 Subject: [PATCH 075/152] fetch full depth of git history for compiler (#6726) [ci] run generate with fetch depth --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 556df6be4..46346136c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup Go uses: actions/setup-go@v6 @@ -152,7 +155,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - fetch-depth: 1 + fetch-depth: 0 fetch-tags: true - name: Download generated artifacts From 11f9e7ac51d888e7a0da8a9f4255941ad0493c37 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:07:47 -0400 Subject: [PATCH 076/152] [ci] add macos bundle (#6727) --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46346136c..c068b46f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -247,6 +247,14 @@ jobs: name: stash-macos path: dist/stash-macos + - name: Upload macOS bundle + # only upload binaries for pull requests + if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} + uses: actions/upload-artifact@v7 + with: + name: Stash.app.zip + path: dist/Stash.app.zip + - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} From feb4346e13b8f19af79a20e3582ddd6d02074184 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:31:48 +1100 Subject: [PATCH 077/152] Maintain sub-folders selection when reselecting folder in filter --- ui/v2.5/src/components/List/Filters/FolderFilter.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx index 38e9d21ec..bf09af9cb 100644 --- a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx @@ -487,16 +487,21 @@ export const SidebarFolderFilter: React.FC< return newCriterion; }, [option.type, filter]); + const subDirsSelected = criterion.value?.depth === -1; + // if there are multiple values or excluded values, then we show none of the // current values const multipleSelected = criterion.value.items.length > 1 || criterion.value.excluded.length > 0; function onSelect(folder: IFolder) { + // maintain sub-folder select if present + const depth = subDirsSelected ? -1 : 0; + const c = criterion.clone() as FolderCriterion; c.value = { items: [{ id: folder.id, label: folder.path }], - depth: 0, + depth, excluded: [], }; @@ -550,8 +555,6 @@ export const SidebarFolderFilter: React.FC< } } - const subDirsSelected = criterion.value?.depth === -1; - const selectedList = useMemo(() => { if (multipleSelected) { return null; From 2bb1df84437d09775cf859644826aacafc9de99e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:45:31 +1100 Subject: [PATCH 078/152] Fix incorrect where clause for gallery parent folder filter (#6737) --- pkg/sqlite/gallery_filter.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index 28f3b8fac..0435f3f57 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -285,7 +285,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier return } - galleryRepository.addFoldersTable(f) + galleryRepository.addFilesTable(f) f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") criterion := *folder @@ -320,7 +320,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier } // combine clauses with OR to handle zip file or folder - c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + c1 := makeClause(fmt.Sprintf("files.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) } @@ -332,7 +332,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier return } - f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) + f.addWhere(fmt.Sprintf("files.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) } } From 3dbb0fcfc9bcad4b0ef2d8b898425b151ad474d8 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 23 Mar 2026 01:10:22 -0400 Subject: [PATCH 079/152] [hwaccel] add envvar for /dev/dri device (#6728) --- pkg/ffmpeg/codec_hardware.go | 8 +++++++- ui/v2.5/src/docs/en/Manual/Configuration.md | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index aa8c75dcc..66480c5bb 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -185,6 +185,12 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf // Prepend input for hardware encoding only func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { + // check for custom /dev/dri device #6435 + driDevice := os.Getenv("STASH_HW_DRI_DEVICE") + if driDevice == "" { + driDevice = "/dev/dri/renderD128" + } + switch toCodec { case VideoCodecN264, VideoCodecN264H: @@ -201,7 +207,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { case VideoCodecV264, VideoCodecVVP9: args = append(args, "-vaapi_device") - args = append(args, "/dev/dri/renderD128") + args = append(args, driDevice) if fullhw { args = append(args, "-hwaccel") args = append(args, "vaapi") diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 2d08f9750..3a856b2d4 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -194,6 +194,8 @@ The following environment variables are also supported: | Environment variable | Remarks | |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +| `STASH_HW_TEST_TIMEOUT` | Sets the Hardware Acceleration test timeout in seconds. Default is 10 seconds +| `STASH_HW_DRI_DEVICE` | Overrides the default `/dev/dri` device used for VAAPI hardware acceleration. Default is `/dev/dri/renderD128` ### Custom favicon From c9d0afee56d4bff5d1049b8e81ef6a200dc411d7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:14:25 +1100 Subject: [PATCH 080/152] Fix tagger modal issues (#6736) * Make modal field/value styling consistent Fixes URL list in studio list styling * Add stash id pill to studio and tag modals * Fix create parent check box * Allow excluding parent studio Disabled the create checkbox if parent studio is not excluded and does not exist. * Don't render modal on every studio * Show dialog when refreshing tags --- .../src/components/Tagger/PerformerModal.tsx | 10 +- .../components/Tagger/scenes/StudioModal.tsx | 69 ++++---- .../Tagger/studios/StudioTagger.tsx | 30 ++-- ui/v2.5/src/components/Tagger/styles.scss | 57 +++--- .../src/components/Tagger/tags/TagModal.tsx | 59 ++++--- .../src/components/Tagger/tags/TagTagger.tsx | 165 ++++++++++++------ 6 files changed, 232 insertions(+), 158 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 9b2434165..b872bcd31 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -92,7 +92,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    {truncate ? ( -
    +
    ) : ( - {text} + {text} )}
    ); @@ -126,7 +126,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    -
    +
      {text.map((t, i) => (
    • diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index a77025d57..1d5149b79 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -7,19 +7,16 @@ import * as GQL from "src/core/generated-graphql"; import { useFindStudio } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; -import { - faCheck, - faExternalLinkAlt, - faTimes, -} from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { excludeFields } from "src/utils/data"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { StashIDPill } from "src/components/Shared/StashID"; interface IStudioDetailsProps { studio: GQL.ScrapedSceneStudioDataFragment; - link?: string; + endpoint?: string; excluded: Record; toggleField: (field: string) => void; isNew?: boolean; @@ -27,7 +24,7 @@ interface IStudioDetailsProps { const StudioDetails: React.FC = ({ studio, - link, + endpoint, excluded, toggleField, isNew = false, @@ -59,13 +56,15 @@ const StudioDetails: React.FC = ({ function maybeRenderField( id: string, text: string | null | undefined, - isSelectable: boolean = true + isSelectable: boolean = true, + messageId?: string ) { if (!text) return; + if (!messageId) messageId = id; return (
      -
      +
      {isSelectable && ( )} - : + :
      @@ -93,7 +92,7 @@ const StudioDetails: React.FC = ({ return (
      -
      +
      {!isNew && (
      -
      +
        {text.map((t, i) => (
      • @@ -123,15 +122,14 @@ const StudioDetails: React.FC = ({ } function maybeRenderStashBoxLink() { - if (!link) return; + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + if (!base || !studio.remote_site_id) return; return ( -
        - - - - -
        + ); } @@ -145,7 +143,12 @@ const StudioDetails: React.FC = ({ {maybeRenderField("details", studio.details)} {maybeRenderField("aliases", studio.aliases)} {maybeRenderField("tags", studio.tags?.map((t) => t.name).join(", "))} - {maybeRenderField("parent_studio", studio.parent?.name, false)} + {maybeRenderField( + "parent_id", + studio.parent?.name, + true, + "parent_studio" + )} {maybeRenderStashBoxLink()}
      @@ -207,6 +210,10 @@ const StudioModal: React.FC = ({ !!studio.parent ); + useEffect(() => { + setCreateParentStudio(!excluded.parent_id && !!studio.parent); + }, [excluded.parent_id, studio.parent]); + let sendParentStudio = true; // The parent studio exists, need to check if it has a Stash ID. const queryResult = useFindStudio(studio.parent?.stored_id ?? ""); @@ -303,30 +310,28 @@ const StudioModal: React.FC = ({ handleStudioCreate(studioData, parentData); } - const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? `${base}studios/${studio.remote_site_id}` : undefined; - const parentLink = base - ? `${base}studios/${studio.parent?.remote_site_id}` - : undefined; - function maybeRenderParentStudio() { // There is no parent studio or it already has a Stash ID - if (!studio.parent || !sendParentStudio) { + if (!studio.parent || !sendParentStudio || excluded.parent_id) { return; } + // force create if there is no current parent studio and parent studio is not excluded + const mustCreateParent = !studio.parent.stored_id; + return (
      -
      + setCreateParentStudio(!createParentStudio)} /> -
      + {maybeRenderParentStudioDetails()}
      ); @@ -342,7 +347,7 @@ const StudioModal: React.FC = ({ studio={studio.parent} excluded={parentExcluded} toggleField={(field) => toggleParentField(field)} - link={parentLink} + endpoint={endpoint} isNew /> ); @@ -365,7 +370,7 @@ const StudioModal: React.FC = ({ studio={studio} excluded={excluded} toggleField={(field) => toggleField(field)} - link={link} + endpoint={endpoint} /> {maybeRenderParentStudio()} diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 645fb19c2..b7b717f7b 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -385,20 +385,6 @@ const StudioTaggerList: React.FC = ({ return (
      - {modalStudio && ( - setModalStudio(undefined)} - modalVisible={modalStudio.stored_id === studio.id} - studio={modalStudio} - handleStudioCreate={handleStudioUpdate} - excludedStudioFields={config.excludedStudioFields} - icon={faTags} - header={intl.formatMessage({ - id: "studio_tagger.update_studio", - })} - endpoint={selectedEndpoint.endpoint} - /> - )}
      @@ -452,6 +438,20 @@ const StudioTaggerList: React.FC = ({ entityName="studio" /> )} + {modalStudio && ( + setModalStudio(undefined)} + modalVisible={!!modalStudio.stored_id} + studio={modalStudio} + handleStudioCreate={handleStudioUpdate} + excludedStudioFields={config.excludedStudioFields} + icon={faTags} + header={intl.formatMessage({ + id: "studio_tagger.update_studio", + })} + endpoint={selectedEndpoint.endpoint} + /> + )}
      )} - : + :
      @@ -110,17 +113,13 @@ const TagModal: React.FC = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; - - if (!link) return; + if (!base || !tag.remote_site_id) return; return ( -
      - - - - -
      + ); } @@ -133,7 +132,7 @@ const TagModal: React.FC = ({ return (
      -
      +
      {isSelectable && (
      diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 8b22a5920..e3674635d 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -11,6 +11,7 @@ import { useJobsSubscribe, mutateStashBoxBatchTagTag, getClient, + useTagCreate, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; @@ -27,6 +28,10 @@ import { BatchAddModal, } from "src/components/Shared/BatchModals"; import { StashBoxSelectorField } from "../StashBoxSelector"; +import { apolloError } from "src/utils"; +import TagModal from "./TagModal"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { uniq } from "lodash-es"; type JobFragment = Pick< GQL.Job, @@ -59,6 +64,7 @@ const TagTaggerList: React.FC = ({ const intl = useIntl(); const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< Record >({}); @@ -94,6 +100,13 @@ const TagTaggerList: React.FC = ({ }, }); + const [modalTag, setModalTag] = useState< + | { + existingTag: GQL.TagListDataFragment; + scrapedTag: GQL.ScrapedSceneTagDataFragment; + } + | undefined + >(); const [error, setError] = useState< Record >({}); @@ -128,64 +141,30 @@ const TagTaggerList: React.FC = ({ setLoading(true); }; + const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); - const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => { + const doBoxUpdate = ( + tag: GQL.TagListDataFragment, + stashID: string, + endpoint: string + ) => { setLoadingUpdate(stashID); setError({ ...error, - [tagID]: undefined, + [tag.id]: undefined, }); stashBoxTagQuery(stashID, endpoint) .then(async (queryData) => { const data = queryData.data?.scrapeSingleTag ?? []; if (data.length > 0) { - const stashboxTag = data[0]; - const updateData: GQL.TagUpdateInput = { - id: tagID, - }; - - if ( - !(config.excludedTagFields ?? []).includes("name") && - stashboxTag.name - ) { - updateData.name = stashboxTag.name; - } - - if ( - stashboxTag.description && - !(config.excludedTagFields ?? []).includes("description") - ) { - updateData.description = stashboxTag.description; - } - - if ( - stashboxTag.alias_list && - stashboxTag.alias_list.length > 0 && - !(config.excludedTagFields ?? []).includes("aliases") - ) { - updateData.aliases = stashboxTag.alias_list; - } - - if (stashboxTag.remote_site_id) { - updateData.stash_ids = await mergeTagStashIDs(tagID, [ - { - endpoint, - stash_id: stashboxTag.remote_site_id, - }, - ]); - } - - const res = await updateTag(updateData); - if (!res?.data?.tagUpdate) { - setError({ - ...error, - [tagID]: { - message: `Failed to update tag`, - details: res?.errors?.[0]?.message ?? "", - }, - }); - } + setModalTag({ + scrapedTag: { + ...data[0], + stored_id: tag.id, + }, + existingTag: tag, + }); } }) .finally(() => setLoadingUpdate(undefined)); @@ -205,6 +184,75 @@ const TagTaggerList: React.FC = ({ setShowBatchUpdate(false); }; + function handleSaveError(tagID: string, name: string, message: string) { + setError({ + ...error, + [tagID]: { + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }, + }); + } + + const handleTagUpdate = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { + const { existingTag, scrapedTag: tag } = modalTag!; + const tagID = existingTag.id; + setModalTag(undefined); + + if (tagID) { + if (parentInput) { + try { + // cannot update parent tags, since there may be many + if (!!input.parent_ids?.length) { + // ignore + } else { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + const parentID = parentRes.data?.tagCreate?.id; + if (parentID) { + // merge parent ids below + input.parent_ids = [parentID]; + } + } + } catch (e) { + handleSaveError(tagID, parentInput.name, apolloError(e)); + } + } + + // always merge parent ids if included + if (input.parent_ids) { + input.parent_ids = uniq( + existingTag.parents.map((p) => p.id).concat(input.parent_ids) + ); + } + + const updateData: GQL.TagUpdateInput = { + ...input, + id: tagID, + }; + updateData.stash_ids = await mergeTagStashIDs( + tagID, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + if (!res?.data?.tagUpdate) + handleSaveError(tagID, tag.name ?? "", res?.errors?.[0]?.message ?? ""); + } + }; + const handleTaggedTag = ( tag: Pick & Partial> @@ -292,7 +340,7 @@ const TagTaggerList: React.FC = ({ } /> + + {children}
      ); diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 6e9c13ea4..eace4b7dc 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -19,6 +19,10 @@ import { BooleanSetting, Setting, SettingGroup } from "../Inputs"; import { ManualLink } from "src/components/Help/context"; import { Icon } from "src/components/Shared/Icon"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { + AutoTagConfirmDialog, + AutoTagWarning, +} from "src/components/Shared/AutoTagConfirmDialog"; import { useSettings } from "../context"; interface IAutoTagOptions { @@ -78,6 +82,7 @@ export const LibraryTasks: React.FC = () => { const [dialogOpen, setDialogOpenState] = useState({ scan: false, autoTag: false, + autoTagAlert: false, identify: false, generate: false, }); @@ -224,12 +229,29 @@ export const LibraryTasks: React.FC = () => { } } + function renderAutoTagAlert() { + return ( + { + setDialogOpen({ autoTagAlert: false }); + runAutoTag(); + }} + onCancel={() => setDialogOpen({ autoTagAlert: false })} + /> + ); + } + function renderAutoTagDialog() { if (!dialogOpen.autoTag) { return; } - return ; + return ( + + + + ); } function onAutoTagDialogClosed(paths?: string[]) { @@ -341,6 +363,7 @@ export const LibraryTasks: React.FC = () => { return ( {renderScanDialog()} + {renderAutoTagAlert()} {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} {renderGenerateDialog()} @@ -426,9 +449,9 @@ export const LibraryTasks: React.FC = () => { variant="secondary" type="submit" className="mr-2" - onClick={() => runAutoTag()} + onClick={() => setDialogOpen({ autoTagAlert: true })} > - + + { + setIsAutoTagAlertOpen(false); if (props.onAutoTag) { props.onAutoTag(); } }} - > - - + onCancel={() => setIsAutoTagAlertOpen(false)} + />
      ); } diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 21e6eb696..024cf1ff5 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -127,11 +127,15 @@ .folder-list { list-style-type: none; margin: 0; - max-height: 30vw; + max-height: 300px; overflow-x: auto; padding-bottom: 0.5rem; padding-top: 1rem; + &:not(:last-child) { + margin-bottom: 1rem; + } + &-item { white-space: nowrap; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e27d51310..cafbb87dc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -512,6 +512,8 @@ "auto_tagging_paths": "Auto tagging the following paths" }, "auto_tag_based_on_filenames": "Auto tag content based on file paths.", + "auto_tag_confirm": "This will attempt to match your content against existing metadata.", + "auto_tag_warning": "This process cannot be undone and may produce incorrect matches.", "auto_tagging": "Auto tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", From b4c7ad4b8120f28364ae9d7d6a5858cf5994d8f0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:29:49 +1100 Subject: [PATCH 083/152] Match exact tag names for batch tagger and show exact matches first for query (#6739) * Enforce exact name matching for tag batch tagger * Sort exact matches first for tag stashbox query --- internal/api/resolver_query_scraper.go | 20 +++++++++++++++++++- internal/manager/task_stash_box_tag.go | 23 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 86d449921..353bb1a32 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strconv" + "strings" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" @@ -363,7 +364,8 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour client := r.newStashBoxClient(*b) var ret []*models.ScrapedTag - out, err := client.QueryTag(ctx, *input.Query) + query := *input.Query + out, err := client.QueryTag(ctx, query) if err != nil { return nil, err @@ -383,6 +385,22 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour }); err != nil { return nil, err } + + // tag name query returns results that may not match the query exactly. + // if there is an exact match, it should be first + if query != "" { + for i, result := range ret { + if strings.EqualFold(result.Name, query) { + // prepend exact match to the front of the slice + if i != 0 { + ret = append([]*models.ScrapedTag{result}, append(ret[:i], ret[i+1:]...)...) + } + + break + } + } + } + return ret, nil } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index ec17fac06..264e7e96c 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -589,8 +590,11 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) + nameQuery := "" + switch { case t.name != nil: + nameQuery = *t.name results, err = client.QueryTag(ctx, *t.name) case t.stashID != nil: results, err = client.QueryTag(ctx, *t.stashID) @@ -616,6 +620,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. if remoteID != "" { results, err = client.QueryTag(ctx, remoteID) } else { + nameQuery = t.tag.Name results, err = client.QueryTag(ctx, t.tag.Name) } } @@ -628,7 +633,23 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. return nil, nil } - result := results[0] + var result *models.ScrapedTag + + // QueryTag returns tags that partially match the name, so find the exact match if searching by name + if nameQuery != "" { + for _, r := range results { + if strings.EqualFold(r.Name, nameQuery) { + result = r + break + } + } + } else { + result = results[0] + } + + if result == nil { + return nil, nil + } if err := r.WithReadTxn(ctx, func(ctx context.Context) error { return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) From 87eabf08710b36d8304b5b0ab3183430c256e702 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:13:34 +1100 Subject: [PATCH 084/152] Show studio name if studio image not set on detail pages (#6716) * Add StudioLogo component If no studio image is set, shows the studio icon with the studio name. * Add option to always show studio text * Implement studio as text option * Add studio logo to image * Clarify existing show studio as text option --- .../Galleries/GalleryDetails/Gallery.tsx | 21 +++-------- ui/v2.5/src/components/Galleries/styles.scss | 2 +- .../components/Images/ImageDetails/Image.tsx | 16 +++------ ui/v2.5/src/components/Images/styles.scss | 2 +- .../components/Scenes/SceneDetails/Scene.tsx | 16 +++------ ui/v2.5/src/components/Scenes/styles.scss | 2 +- .../SettingsInterfacePanel.tsx | 8 +++++ ui/v2.5/src/components/Shared/StudioLogo.tsx | 35 +++++++++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 12 +++++++ ui/v2.5/src/core/config.ts | 2 ++ ui/v2.5/src/docs/en/Manual/Interface.md | 4 +-- ui/v2.5/src/locales/en-GB.json | 7 +++- 12 files changed, 80 insertions(+), 47 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/StudioLogo.tsx diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 18cbeff96..1fce02b32 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -1,11 +1,6 @@ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; -import { - useHistory, - Link, - RouteComponentProps, - Redirect, -} from "react-router-dom"; +import { useHistory, RouteComponentProps, Redirect } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; @@ -50,6 +45,7 @@ import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -66,6 +62,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); @@ -415,17 +412,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => {
      - {gallery.studio && ( -

      - - {`${gallery.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index b59da415e..b05be7856 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -17,7 +17,7 @@ order: 1; } - .gallery-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index f79d95fca..f885c21bb 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -1,7 +1,7 @@ import { Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useFindImage, @@ -37,6 +37,7 @@ import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; import { GenerateDialog } from "src/components/Dialogs/GenerateDialog"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { image: GQL.ImageDataFragment; @@ -51,6 +52,7 @@ const ImagePage: React.FC = ({ image }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); @@ -326,17 +328,7 @@ const ImagePage: React.FC = ({ image }) => {
      - {image.studio && ( -

      - - {`${image.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 43ac56590..0a8ca760e 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -9,7 +9,7 @@ order: 1; } - .image-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 435b9dce2..35bef7efb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -7,7 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -56,6 +56,7 @@ import { PatchComponent, PatchContainerComponent } from "src/patch"; import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -190,6 +191,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [showDraftModal, setShowDraftModal] = useState(false); const boxes = configuration?.general?.stashBoxes ?? []; @@ -674,17 +676,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { >
      - {scene.studio && ( -

      - - {`${scene.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 8eb63f378..dda699744 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -74,7 +74,7 @@ order: 1; } - .scene-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 84608107d..5b4e8c5de 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -372,6 +372,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( saveInterface({ showStudioAsText: v })} /> @@ -692,6 +693,13 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( checked={ui.compactExpandedDetails ?? undefined} onChange={(v) => saveUI({ compactExpandedDetails: v })} /> + saveUI({ showStudioText: v })} + /> diff --git a/ui/v2.5/src/components/Shared/StudioLogo.tsx b/ui/v2.5/src/components/Shared/StudioLogo.tsx new file mode 100644 index 000000000..0da9d692a --- /dev/null +++ b/ui/v2.5/src/components/Shared/StudioLogo.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import { Studio } from "src/core/generated-graphql"; +import { Icon } from "./Icon"; +import { faVideo } from "@fortawesome/free-solid-svg-icons"; + +export const StudioLogo: React.FC<{ + studio: Pick | undefined | null; + showText?: boolean; +}> = ({ studio, showText = false }) => { + if (!studio) return null; + + const hasLogo = + !showText && + studio.image_path && + !studio.image_path.endsWith("default=true"); + + return ( +

      + + {hasLogo ? ( + {`${studio.name} + ) : ( + + + {studio.name} + + )} + +

      + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 024cf1ff5..acc8556eb 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1256,3 +1256,15 @@ input[type="range"].double-range-slider-max { .text-input + .input-group-append .btn.minimal { background-color: $textfield-bg; } + +.studio-logo a:hover { + color: inherit; +} + +.studio-logo .studio-name { + color: $text-color; + font-size: 1.5rem; + font-weight: 500; + margin-top: 0.5rem; + text-align: center; +} diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index a757c7a06..e0cf008b5 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -49,6 +49,8 @@ export interface IUIConfig { showLinksOnPerformerCard?: boolean; showTagCardOnHover?: boolean; + showStudioText?: boolean; + previewVolume?: number; abbreviateCounters?: boolean; diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 951fb3323..a045421a3 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -19,9 +19,9 @@ The Scene Wall and Marker pages display scene preview videos (mp4) by default. T > **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated. -## Show Studios as text +## Show studio overlay as text -By default, a scene's studio will be shown as an image overlay. Checking this option changes this to display studios as a text name instead. +By default, in the grid card view the studio will be shown as an image overlay of the studio logo. Checking this option changes this to display studios as a text name instead. ## Scene Player options diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index cafbb87dc..37b6b6d44 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -712,6 +712,10 @@ "show_all_details": { "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column.", "heading": "Show all details" + }, + "show_studio_text": { + "description": "Always display studio name as text on details views instead of an icon.", + "heading": "Show studio as text" } }, "editing": { @@ -817,7 +821,8 @@ "scene_list": { "heading": "Grid View", "options": { - "show_studio_as_text": "Display studio overlay as text" + "show_studio_as_text": "Display studio overlay as text", + "show_studio_as_text_desc": "By default, the studio logo is shown on cards in the grid. Enable this option to always show the studio name as text instead." } }, "scene_player": { From 2e48dbfc634b29805e7a784fc095db0c1815a70b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:32:30 +1100 Subject: [PATCH 085/152] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0310.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md index afb618507..d7403c3b7 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0310.md +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -50,10 +50,13 @@ * Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) * Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) * Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) +* Added confirmation dialog to Auto Tag task. ([#6735](https://github.com/stashapp/stash/pull/6735)) +* Studio now shows the studio name instead of the studio image if the image is not set or if (new) `Show studio as text` is true. ([#6716](https://github.com/stashapp/stash/pull/6716)) * Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443)) * Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447)) * Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478)) * Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704)) +* VAAPI dri device can now be overridden using `STASH_HW_DRI_DEVICE` environment variable. ([#6728](https://github.com/stashapp/stash/pull/6728)) * Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701)) * Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448)) From fd480c5a3ef60e5b9545d39c70e9f3c301be53d8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:03:58 +1100 Subject: [PATCH 086/152] Exclude zip folders when browsing scenes and galleries (#6740) * Add short cuts when only getting zip/folder ids * Don't show zip folders when viewing scenes and galleries. Zip folders have no results for scenes and galleries, but will for images. --- internal/api/resolver.go | 8 ++ internal/api/resolver_model_folder.go | 31 +++++++ ui/v2.5/graphql/queries/folder.graphql | 11 ++- .../components/List/Filters/FolderFilter.tsx | 83 ++++++++++++++----- ui/v2.5/src/core/StashService.ts | 5 +- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 061d0e1a9..b1cec1c9d 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" @@ -145,6 +146,13 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) return r.repository.WithReadTxn(ctx, fn) } +// idOnly returns true if the query is only asking for the id field. +// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id. +func (r *Resolver) idOnly(ctx context.Context) bool { + fields := graphql.CollectAllFields(ctx) + return len(fields) == 1 && fields[0] == "id" +} + func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Wall(ctx, q) diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go index 1fcc144f3..725ca34f8 100644 --- a/internal/api/resolver_model_folder.go +++ b/internal/api/resolver_model_folder.go @@ -17,15 +17,31 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) ( return nil, nil } + if r.idOnly(ctx) { + return &models.Folder{ID: *obj.ParentFolderID}, nil + } + return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) } +func foldersFromIDs(ids []models.FolderID) []*models.Folder { + ret := make([]*models.Folder, len(ids)) + for i, id := range ids { + ret[i] = &models.Folder{ID: id} + } + return ret +} + 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 } + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + var errs []error ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) return ret, firstError(errs) @@ -37,11 +53,26 @@ func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([] return nil, err } + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + 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) { + // shortcut for id only queries + if r.idOnly(ctx) { + if obj.ZipFileID == nil { + return nil, nil + } + + return &BasicFile{ + BaseFile: &models.BaseFile{ID: *obj.ZipFileID}, + }, nil + } + return zipFileResolver(ctx, obj.ZipFileID) } diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql index 81f431786..b1119cd61 100644 --- a/ui/v2.5/graphql/queries/folder.graphql +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -1,7 +1,10 @@ -query FindRootFoldersForSelect { +query FindRootFoldersForSelect($zip_file_filter: MultiCriterionInput) { findFolders( filter: { per_page: -1, sort: "path", direction: ASC } - folder_filter: { parent_folder: { modifier: IS_NULL } } + folder_filter: { + parent_folder: { modifier: IS_NULL } + zip_file: $zip_file_filter + } ) { count folders { @@ -34,6 +37,10 @@ query FindFolderHierarchyForIDs($ids: [ID!]!) { # the parent folders will be expanded, so we need the child folders sub_folders { ...SelectFolderData + # get zip file so we can filter out zip folders if needed + zip_file { + id + } } } } diff --git a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx index 95b5121f9..3eaaf0427 100644 --- a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx @@ -1,6 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { + CriterionModifier, + FilterMode, FolderDataFragment, + MultiCriterionInput, useFindFolderHierarchyForIDsQuery, useFindFoldersForQueryQuery, useFindRootFoldersForSelectQuery, @@ -159,21 +162,47 @@ function mergeFolderMaps(base: IFolder[], update: IFolder[]): IFolder[] { return ret; } -function useFolderMap( - query: string, - skip?: boolean, - initialSelected?: string[] -) { +function useFolderMap(props: { + query: string; + skip?: boolean; + initialSelected?: string[]; + mode?: FilterMode; +}) { + const { query, skip = false, initialSelected, mode } = props; + const [cachedInitialSelected] = useState(initialSelected ?? []); + // exclude zip folders for scenes and galleries + const excludeZipFolders = + mode === FilterMode.Scenes || mode === FilterMode.Galleries; + + const zipFileFilter: MultiCriterionInput | undefined = useMemo( + () => + excludeZipFolders + ? { + modifier: CriterionModifier.IsNull, + } + : undefined, + [excludeZipFolders] + ); + + const folderFilterForQuery = useMemo( + () => (zipFileFilter ? { zip_file: zipFileFilter } : undefined), + [zipFileFilter] + ); + const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ skip, + variables: { + zip_file_filter: zipFileFilter, + }, }); const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ skip: !query, variables: { filter: { q: query, per_page: 200 }, + folder_filter: folderFilterForQuery, }, }); @@ -213,11 +242,14 @@ function useFolderMap( existing = { ...folder.parent_folders[i], expanded: true, - children: folder.parent_folders[i].sub_folders.map((f) => ({ - ...f, - expanded: false, - children: undefined, - })), + children: folder.parent_folders[i].sub_folders + // filter out zip folders if needed + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), }; ret.push(existing); } @@ -243,11 +275,14 @@ function useFolderMap( existing = { ...existing, expanded: true, - children: thisFolder.sub_folders.map((f) => ({ - ...f, - expanded: false, - children: undefined, - })), + // filter out zip folders if needed + children: thisFolder.sub_folders + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), }; currentParent!.children![existingIndex] = existing; @@ -255,7 +290,7 @@ function useFolderMap( } }); return ret; - }, [initialSelectedResult]); + }, [initialSelectedResult, excludeZipFolders]); const mergedRootFolders = useMemo(() => { if (query) { @@ -347,7 +382,10 @@ function useFolderMap( // query children folders if not already loaded if (folder.children === undefined) { - const subFolderResult = await queryFindSubFolders(folder.id); + const subFolderResult = await queryFindSubFolders( + folder.id, + excludeZipFolders + ); setFolderMap((current) => current.map( replaceFolder({ @@ -419,17 +457,19 @@ export const FolderSelector: React.FC<{ interface IInputFilterProps { criterion: FolderCriterion; setCriterion: (c: FolderCriterion) => void; + mode?: FilterMode; } export const FolderFilter: React.FC = ({ criterion, setCriterion, + mode, }) => { const intl = useIntl(); const [query, setQuery] = useState(""); const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); - const { folderMap, onToggleExpanded } = useFolderMap(query); + const { folderMap, onToggleExpanded } = useFolderMap({ query, mode }); const messages = defineMessages({ sub_folder_depth: { @@ -599,11 +639,12 @@ export const SidebarFolderFilter: React.FC< const multipleSelected = criterion.value.items.length > 1 || criterion.value.excluded.length > 0; - const { folderMap, onToggleExpanded } = useFolderMap( + const { folderMap, onToggleExpanded } = useFolderMap({ query, skip, - criterion.value.items.map((i) => i.id) - ); + initialSelected: criterion.value.items.map((i) => i.id), + mode: filter.mode, + }); function onSelect(folder: IFolder) { // maintain sub-folder select if present diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index bfd76f7ee..33b778343 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -515,12 +515,15 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); -export const queryFindSubFolders = (id: string) => +export const queryFindSubFolders = (id: string, excludeZipFolders?: boolean) => client.query({ query: GQL.FindFoldersForQueryDocument, variables: { folder_filter: { parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals }, + zip_file: excludeZipFolders + ? { modifier: GQL.CriterionModifier.IsNull } + : undefined, }, filter: { per_page: -1, From eeee081eb7d6979be5a8f399e5cce136882be06e Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:36:31 +0200 Subject: [PATCH 087/152] Refactor README.md for better clarity and structure --- README.md | 58 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5ccefe4bc..2d90a76ea 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) -* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. -* Stash supports a wide variety of both video and image formats. -* You can tag videos and find them later. -* Stash provides statistics about performers, tags, studios and more. +- Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. +- Stash supports a wide variety of both video and image formats. +- You can tag videos and find them later. +- Stash provides statistics about performers, tags, studios and more. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. @@ -24,17 +24,19 @@ For further information you can consult the [documentation](https://docs.stashap # Installing Stash +> [!tip] Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). -#### Windows Users: - -As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ -At least Windows 10 or Server 2016 is required. - -#### Mac Users: - -As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. -Stash can still be run through docker on older versions of macOS. +> [!important] +>**Windows Users** +> +>As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ +>At least Windows 10 or Server 2016 is required. +> +>**macOS Users** +> +> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. +> Stash can still be run through docker on older versions of macOS. Windows | macOS | Linux | Docker :---:|:---:|:---:|:---: @@ -85,23 +87,23 @@ The badge below shows the current translation status of Stash across all support Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. -- Documentation - - Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting. - - In-app manual: press Shift + ? in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - - FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers. - - Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips. +### Documentation +- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting. +- [In-app manual](https://docs.stashapp.cc/in-app-manual) press Shift + ? in the app or view the manual online. +- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers. +- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-to’s and tips. -- Community & discussion - - Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions. - - Discord: https://discord.gg/2TsNFKt - real-time chat and community support. - - GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions. - - Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space. +### Community & discussion +- [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions. +- [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support. +- [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions. +- [Lemmy community](https://discuss.online/c/stashapp) - board-style community space. -- Community scrapers & plugins - - Metadata sources: https://docs.stashapp.cc/metadata-sources/ - - Plugins: https://docs.stashapp.cc/plugins/ - - Themes: https://docs.stashapp.cc/themes/ - - Other projects: https://docs.stashapp.cc/other-projects/ +### Community scrapers & plugins +- [Metadata sources](https://docs.stashapp.cc/metadata-sources/) +- [Plugins](https://docs.stashapp.cc/plugins/) +- [Themes](https://docs.stashapp.cc/themes/) +- [Other projects](https://docs.stashapp.cc/other-projects/) # For Developers From c861d3991a9aa4404ff6739b2515343957340355 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:01:43 -0500 Subject: [PATCH 088/152] Fix 'not equals' custom field to include unset objects (#6742) * Fix custom field 'not equals' to include unset objects * also fix Excludes and NotBetween null handling --- pkg/sqlite/custom_fields.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index d78e3f9ab..22dbbfeb2 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -261,8 +261,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierNotEquals: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT IN %s OR %[1]s.value IS NULL)", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierIncludes: clauses := make([]sqlClause, len(cv)) for i, v := range cv { @@ -272,7 +272,7 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str f.whereClauses = append(f.whereClauses, clauses...) case models.CriterionModifierExcludes: for _, v := range cv { - f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v)) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT LIKE ? OR %[1]s.value IS NULL)", joinAs), fmt.Sprintf("%%%v%%", v)) } h.leftJoin(f, joinAs, cc.Field) case models.CriterionModifierMatchesRegex: @@ -315,8 +315,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1]) case models.CriterionModifierNotBetween: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1]) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%s.value NOT BETWEEN ? AND ? OR %[1]s.value IS NULL)", joinAs), cv[0], cv[1]) case models.CriterionModifierLessThan: if len(cv) != 1 { f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv))) From 020c242ea6c42cec7e0b38ce37c57a0af76a9bfe Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:07:54 -0700 Subject: [PATCH 089/152] Fix: Remove padFuzzyDate From Performer (#6757) --- pkg/stashbox/performer.go | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 589fd29b6..5b25b4a59 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -242,11 +242,11 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc } if p.BirthDate != nil { - sp.Birthdate = padFuzzyDate(p.BirthDate) + sp.Birthdate = p.BirthDate } if p.DeathDate != nil { - sp.DeathDate = padFuzzyDate(p.DeathDate) + sp.DeathDate = p.DeathDate } if p.Gender != nil { @@ -290,23 +290,6 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc return sp } -func padFuzzyDate(date *string) *string { - if date == nil { - return nil - } - - var paddedDate string - switch len(*date) { - case 10: - paddedDate = *date - case 7: - paddedDate = fmt.Sprintf("%s-01", *date) - case 4: - paddedDate = fmt.Sprintf("%s-01-01", *date) - } - return &paddedDate -} - // FindPerformerByID queries stash-box for a performer by ID. func (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) { performer, err := c.client.FindPerformerByID(ctx, id) From 8af2cfe5255f70d8bc0864e3a275571d2db96de0 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:09:28 -0500 Subject: [PATCH 090/152] Add mutex to repositoryCache for thread safety (#6741) * Add mutex to package cache to prevent concurrent map write crash * use sync.Once for cache init --- pkg/pkg/cache.go | 32 +++++++++++++++++++++++--------- pkg/pkg/manager.go | 8 +++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/pkg/pkg/cache.go b/pkg/pkg/cache.go index 9d36bdd1d..e94b2cb41 100644 --- a/pkg/pkg/cache.go +++ b/pkg/pkg/cache.go @@ -1,6 +1,7 @@ package pkg import ( + "sync" "time" ) @@ -10,22 +11,23 @@ type cacheEntry struct { } type repositoryCache struct { + mu sync.RWMutex // cache maps the URL to the last modified time and the data cache map[string]cacheEntry } -func (c *repositoryCache) ensureCache() { - if c.cache == nil { - c.cache = make(map[string]cacheEntry) - } -} - func (c *repositoryCache) lastModified(url string) *time.Time { if c == nil { return nil } - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -36,7 +38,13 @@ func (c *repositoryCache) lastModified(url string) *time.Time { } func (c *repositoryCache) getPackageList(url string) []RemotePackage { - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -51,7 +59,13 @@ func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []R return } - c.ensureCache() + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + c.cache = make(map[string]cacheEntry) + } + c.cache[url] = cacheEntry{ lastModified: lastModified, data: data, diff --git a/pkg/pkg/manager.go b/pkg/pkg/manager.go index 18fa4e0d1..4024191ad 100644 --- a/pkg/pkg/manager.go +++ b/pkg/pkg/manager.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path/filepath" + "sync" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -31,13 +32,14 @@ type Manager struct { Client *http.Client - cache *repositoryCache + cacheOnce sync.Once + cache *repositoryCache } func (m *Manager) getCache() *repositoryCache { - if m.cache == nil { + m.cacheOnce.Do(func() { m.cache = &repositoryCache{} - } + }) return m.cache } From af07fea2890003fc2e7ad05ec1ebc5af28d51cdf Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 29 Mar 2026 19:57:16 -0400 Subject: [PATCH 091/152] [CI] add vips-heif (#6765) --- docker/ci/x86_64/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 6a9c6b76d..2161cb6af 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ -RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ +RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools vips-heif \ && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml From fe2a8eb0fdeb1180e5377bc28f4161abd7ac515a Mon Sep 17 00:00:00 2001 From: eb2292 <46068962+eb2292@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:04:10 -0400 Subject: [PATCH 092/152] Add keyboard shortcut "d d" to delete scene (#6755) Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> --- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 2 ++ ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md | 1 + 2 files changed, 3 insertions(+) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 35bef7efb..7d1b245fc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -256,6 +256,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.bind("p p", () => onQueuePrevious()); Mousetrap.bind("p r", () => onQueueRandom()); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("d d", () => setIsDeleteAlertOpen(true)); Mousetrap.bind("c c", () => { onGenerateScreenshot(getPlayerPosition()); }); @@ -271,6 +272,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.unbind("i"); Mousetrap.unbind("h"); Mousetrap.unbind("o"); + Mousetrap.unbind("d d"); Mousetrap.unbind("p n"); Mousetrap.unbind("p p"); Mousetrap.unbind("p r"); diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index f6cd29334..93ba817f6 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -64,6 +64,7 @@ | `,` | Hide/Show sidebar | | `.` | Hide/Show scene scrubber | | `o` | Increment O-Counter | +| `d d` | Delete scene | | Ratings || | `r {1-5}` | Set rating (stars) | | `r 0` | Unset rating (stars) | From 86188e5ff7067c6fe78d7ad5785555cf0b15d5ef Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:07:04 +0100 Subject: [PATCH 093/152] Use StashIDPill for displaying the scraped stash ID (#6761) This is more consistent with other places that stash IDs are shown, simplifies the code a bit, and lets you see at a glance which stash box is being used. --- .../Tagger/scenes/StashSearchResult.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index f39fef103..add295c49 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -29,9 +29,9 @@ import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; import StudioResult from "./StudioResult"; import { useInitialState } from "src/hooks/state"; -import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { compareScenesForSort } from "./utils"; +import { StashIDPill } from "src/components/Shared/StashID"; const getDurationIcon = (matchPercentage: number) => { if (matchPercentage > 65) @@ -325,15 +325,6 @@ const StashSearchResult: React.FC = ({ } }, [isActive, loading, stashScene, index, resolveScene, scene]); - const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint - ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint) - : undefined; - const stashBoxURL = useMemo(() => { - if (stashBoxBaseURL) { - return `${stashBoxBaseURL}scenes/${scene.remote_site_id}`; - } - }, [scene, stashBoxBaseURL]); - const setExcludedField = (name: string, value: boolean) => setExcludedFields({ ...excludedFields, @@ -680,16 +671,20 @@ const StashSearchResult: React.FC = ({ }; const maybeRenderStashBoxID = () => { - if (scene.remote_site_id && stashBoxURL) { + if (scene.remote_site_id && currentSource?.sourceInput.stash_box_endpoint) { return (
      setExcludedField(fields.stash_ids, v)} > - - {scene.remote_site_id} - +
      ); From 0a4b427e1df109ae0092f9c2e0a84d8f272f5f1a Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:09:17 +0100 Subject: [PATCH 094/152] Show stash-box name in studio/performer tagger (#6759) --- ui/v2.5/src/components/Tagger/StashBoxSelector.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx index 3f5f103a6..c47bc73f1 100644 --- a/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx +++ b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx @@ -1,6 +1,7 @@ import { Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { StashBox } from "src/core/generated-graphql"; +import { useConfigurationContext } from "src/hooks/Config"; interface IStashBoxSelectorProps { stashBoxes: StashBox[]; @@ -13,6 +14,15 @@ export const StashBoxSelector: React.FC = ({ selectedEndpoint, onEndpointChange, }) => { + const { configuration } = useConfigurationContext(); + + function stashboxNameForEndpoint(endpoint: string) { + let box = configuration?.general.stashBoxes.find( + (sb) => sb.endpoint === endpoint + ); + return `stash-box: ${box?.name ?? endpoint}`; + } + return ( = ({ )} {stashBoxes.map((i) => ( ))} From 1e0b9902a34bc3324f0b052987ff0d9b3ab44524 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:18:45 -0500 Subject: [PATCH 095/152] Fix lightbox not reading scale-up setting from config (#6743) --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 43 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 65c15024c..41c6d4fad 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -206,8 +206,25 @@ export const LightboxComponent: React.FC = ({ setLightboxSettings({ slideshowDelay: v }); } + const scaleUp = + lightboxSettings?.scaleUp ?? + config?.interface.imageLightbox.scaleUp ?? + false; + + const resetZoomOnNav = + lightboxSettings?.resetZoomOnNav ?? + config?.interface.imageLightbox.resetZoomOnNav ?? + false; + + const scrollMode = + lightboxSettings?.scrollMode ?? + config?.interface.imageLightbox.scrollMode ?? + GQL.ImageLightboxScrollMode.Zoom; + const displayMode = - lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy; + lightboxSettings?.displayMode ?? + config?.interface.imageLightbox.displayMode ?? + GQL.ImageLightboxDisplayMode.FitXy; const oldDisplayMode = useRef(displayMode); function setDisplayMode(v: GQL.ImageLightboxDisplayMode) { @@ -250,13 +267,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); oldIndex.current = index; - }, [index, images.length, lightboxSettings?.resetZoomOnNav]); + }, [index, images.length, resetZoomOnNav]); const getNavOffset = useCallback(() => { if (images.length < 2) return; @@ -288,13 +305,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); } oldDisplayMode.current = displayMode; - }, [displayMode, lightboxSettings?.resetZoomOnNav]); + }, [displayMode, resetZoomOnNav]); const selectIndex = (e: React.MouseEvent, i: number) => { setIndex(i); @@ -635,7 +652,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.scale_up.label", })} - checked={lightboxSettings?.scaleUp ?? false} + checked={scaleUp} disabled={displayMode === GQL.ImageLightboxDisplayMode.Original} onChange={(v) => setScaleUp(v.currentTarget.checked)} /> @@ -655,7 +672,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.reset_zoom_on_nav", })} - checked={lightboxSettings?.resetZoomOnNav ?? false} + checked={resetZoomOnNav} onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)} /> @@ -674,10 +691,7 @@ export const LightboxComponent: React.FC = ({ onChange={(e) => setScrollMode(e.target.value as GQL.ImageLightboxScrollMode) } - value={ - lightboxSettings?.scrollMode ?? - GQL.ImageLightboxScrollMode.Zoom - } + value={scrollMode} className="btn-secondary mx-1 mb-1" >