diff --git a/graphql/documents/data/scene-marker.graphql b/graphql/documents/data/scene-marker.graphql index 7eacbcea8..30091d857 100644 --- a/graphql/documents/data/scene-marker.graphql +++ b/graphql/documents/data/scene-marker.graphql @@ -12,10 +12,12 @@ fragment SceneMarkerData on SceneMarker { primary_tag { id name + aliases } tags { id name + aliases } } diff --git a/graphql/documents/data/tag-slim.graphql b/graphql/documents/data/tag-slim.graphql index 61fd320e5..26b7c277a 100644 --- a/graphql/documents/data/tag-slim.graphql +++ b/graphql/documents/data/tag-slim.graphql @@ -1,5 +1,6 @@ fragment SlimTagData on Tag { id name + aliases image_path } diff --git a/graphql/documents/data/tag.graphql b/graphql/documents/data/tag.graphql index 17d65b908..cf1e2c050 100644 --- a/graphql/documents/data/tag.graphql +++ b/graphql/documents/data/tag.graphql @@ -1,6 +1,7 @@ fragment TagData on Tag { id name + aliases image_path scene_count scene_marker_count diff --git a/graphql/documents/mutations/tag.graphql b/graphql/documents/mutations/tag.graphql index 6e2cd0b99..0b3d72451 100644 --- a/graphql/documents/mutations/tag.graphql +++ b/graphql/documents/mutations/tag.graphql @@ -1,5 +1,5 @@ -mutation TagCreate($name: String!, $image: String) { - tagCreate(input: { name: $name, image: $image }) { +mutation TagCreate($input: TagCreateInput!) { + tagCreate(input: $input) { ...TagData } } diff --git a/graphql/documents/queries/misc.graphql b/graphql/documents/queries/misc.graphql index 3df25042e..0816d39f5 100644 --- a/graphql/documents/queries/misc.graphql +++ b/graphql/documents/queries/misc.graphql @@ -33,6 +33,7 @@ query AllTagsForFilter { allTags { id name + aliases } } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index e0f0c0e90..96387deed 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -211,6 +211,12 @@ input TagFilterType { OR: TagFilterType NOT: TagFilterType + """Filter by tag name""" + name: StringCriterionInput + + """Filter by tag aliases""" + aliases: StringCriterionInput + """Filter to only include tags missing this property""" is_missing: String diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index ec47a7f1d..a94f1509b 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -1,6 +1,7 @@ type Tag { id: ID! name: String! + aliases: [String!]! created_at: Time! updated_at: Time! @@ -14,6 +15,7 @@ type Tag { input TagCreateInput { name: String! + aliases: [String!] """This should be a URL or a base64 encoded data URL""" image: String @@ -21,7 +23,8 @@ input TagCreateInput { input TagUpdateInput { id: ID! - name: String! + name: String + aliases: [String!] """This should be a URL or a base64 encoded data URL""" image: String diff --git a/pkg/api/resolver_model_tag.go b/pkg/api/resolver_model_tag.go index a4ec3c46c..a863cd233 100644 --- a/pkg/api/resolver_model_tag.go +++ b/pkg/api/resolver_model_tag.go @@ -10,6 +10,17 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) { + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.Tag().GetAliases(obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, err +} + func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var count int if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { diff --git a/pkg/api/resolver_mutation_tag.go b/pkg/api/resolver_mutation_tag.go index df11e06d0..953f1ce5c 100644 --- a/pkg/api/resolver_mutation_tag.go +++ b/pkg/api/resolver_mutation_tag.go @@ -6,8 +6,8 @@ import ( "strconv" "time" - "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/tag" "github.com/stashapp/stash/pkg/utils" ) @@ -31,24 +31,34 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate } } - // Start the transaction and save the tag - var tag *models.Tag + // Start the transaction and save the t + var t *models.Tag if err := r.withTxn(ctx, func(repo models.Repository) error { qb := repo.Tag() // ensure name is unique - if err := manager.EnsureTagNameUnique(newTag, qb); err != nil { + if err := tag.EnsureTagNameUnique(0, newTag.Name, qb); err != nil { return err } - tag, err = qb.Create(newTag) + t, err = qb.Create(newTag) if err != nil { return err } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(tag.ID, imageData); err != nil { + if err := qb.UpdateImage(t.ID, imageData); err != nil { + return err + } + } + + if len(input.Aliases) > 0 { + if err := tag.EnsureAliasesUnique(t.ID, input.Aliases, qb); err != nil { + return err + } + + if err := qb.UpdateAliases(t.ID, input.Aliases); err != nil { return err } } @@ -58,7 +68,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate return nil, err } - return tag, nil + return t, nil } func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdateInput) (*models.Tag, error) { @@ -68,12 +78,6 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate return nil, err } - updatedTag := models.Tag{ - ID: tagID, - Name: input.Name, - UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()}, - } - var imageData []byte translator := changesetTranslator{ @@ -90,39 +94,56 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate } // Start the transaction and save the tag - var tag *models.Tag + var t *models.Tag if err := r.withTxn(ctx, func(repo models.Repository) error { qb := repo.Tag() // ensure name is unique - existing, err := qb.Find(tagID) + t, err = qb.Find(tagID) if err != nil { return err } - if existing == nil { + if t == nil { return fmt.Errorf("Tag with ID %d not found", tagID) } - if existing.Name != updatedTag.Name { - if err := manager.EnsureTagNameUnique(updatedTag, qb); err != nil { - return err - } + updatedTag := models.TagPartial{ + ID: tagID, + UpdatedAt: &models.SQLiteTimestamp{Timestamp: time.Now()}, } - tag, err = qb.Update(updatedTag) + if input.Name != nil && t.Name != *input.Name { + if err := tag.EnsureTagNameUnique(tagID, *input.Name, qb); err != nil { + return err + } + + updatedTag.Name = input.Name + } + + t, err = qb.Update(updatedTag) if err != nil { return err } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(tag.ID, imageData); err != nil { + if err := qb.UpdateImage(tagID, imageData); err != nil { return err } } else if imageIncluded { // must be unsetting - if err := qb.DestroyImage(tag.ID); err != nil { + if err := qb.DestroyImage(tagID); err != nil { + return err + } + } + + if translator.hasField("aliases") { + if err := tag.EnsureAliasesUnique(tagID, input.Aliases, qb); err != nil { + return err + } + + if err := qb.UpdateAliases(tagID, input.Aliases); err != nil { return err } } @@ -132,7 +153,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate return nil, err } - return tag, nil + return t, nil } func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestroyInput) (bool, error) { diff --git a/pkg/api/resolver_mutation_tag_test.go b/pkg/api/resolver_mutation_tag_test.go index d32490d9f..371b88d33 100644 --- a/pkg/api/resolver_mutation_tag_test.go +++ b/pkg/api/resolver_mutation_tag_test.go @@ -30,11 +30,38 @@ func TestTagCreate(t *testing.T) { r := newResolver() tagRW := r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter) - tagRW.On("FindByName", existingTagName, true).Return(&models.Tag{ - ID: existingTagID, - Name: existingTagName, - }, nil).Once() - tagRW.On("FindByName", errTagName, true).Return(nil, nil).Once() + + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + tagFilterForName := func(n string) *models.TagFilterType { + return &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } + } + + tagFilterForAlias := func(n string) *models.TagFilterType { + return &models.TagFilterType{ + Aliases: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } + } + + tagRW.On("Query", tagFilterForName(existingTagName), findFilter).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, 1, nil).Once() + tagRW.On("Query", tagFilterForName(errTagName), findFilter).Return(nil, 0, nil).Once() + tagRW.On("Query", tagFilterForAlias(errTagName), findFilter).Return(nil, 0, nil).Once() expectedErr := errors.New("TagCreate error") tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, expectedErr) @@ -55,7 +82,8 @@ func TestTagCreate(t *testing.T) { r = newResolver() tagRW = r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter) - tagRW.On("FindByName", tagName, true).Return(nil, nil).Once() + tagRW.On("Query", tagFilterForName(tagName), findFilter).Return(nil, 0, nil).Once() + tagRW.On("Query", tagFilterForAlias(tagName), findFilter).Return(nil, 0, nil).Once() tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(&models.Tag{ ID: newTagID, Name: tagName, diff --git a/pkg/autotag/integration_test.go b/pkg/autotag/integration_test.go index 6c890c359..685cae74a 100644 --- a/pkg/autotag/integration_test.go +++ b/pkg/autotag/integration_test.go @@ -459,7 +459,12 @@ func TestParseTagScenes(t *testing.T) { for _, s := range tags { if err := withTxn(func(r models.Repository) error { - return TagScenes(s, nil, r.Scene()) + aliases, err := r.Tag().GetAliases(s.ID) + if err != nil { + return err + } + + return TagScenes(s, nil, aliases, r.Scene()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -481,7 +486,7 @@ func TestParseTagScenes(t *testing.T) { t.Errorf("Error getting scene tags: %s", err.Error()) } - // title is only set on scenes where we expect performer to be set + // title is only set on scenes where we expect tag to be set if scene.Title.String == scene.Path && len(tags) == 0 { t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path) } else if scene.Title.String != scene.Path && len(tags) > 0 { @@ -604,7 +609,12 @@ func TestParseTagImages(t *testing.T) { for _, s := range tags { if err := withTxn(func(r models.Repository) error { - return TagImages(s, nil, r.Image()) + aliases, err := r.Tag().GetAliases(s.ID) + if err != nil { + return err + } + + return TagImages(s, nil, aliases, r.Image()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -749,7 +759,12 @@ func TestParseTagGalleries(t *testing.T) { for _, s := range tags { if err := withTxn(func(r models.Repository) error { - return TagGalleries(s, nil, r.Gallery()) + aliases, err := r.Tag().GetAliases(s.ID) + if err != nil { + return err + } + + return TagGalleries(s, nil, aliases, r.Gallery()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } diff --git a/pkg/autotag/tag.go b/pkg/autotag/tag.go index 2f8f74841..c1d2cf271 100644 --- a/pkg/autotag/tag.go +++ b/pkg/autotag/tag.go @@ -25,37 +25,62 @@ func getMatchingTags(path string, tagReader models.TagReader) ([]*models.Tag, er return ret, nil } -func getTagTagger(p *models.Tag) tagger { - return tagger{ +func getTagTaggers(p *models.Tag, aliases []string) []tagger { + ret := []tagger{{ ID: p.ID, Type: "tag", Name: p.Name, + }} + + for _, a := range aliases { + ret = append(ret, tagger{ + ID: p.ID, + Type: "tag", + Name: a, + }) } + + return ret } // TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag. -func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error { - t := getTagTagger(p) +func TagScenes(p *models.Tag, paths []string, aliases []string, rw models.SceneReaderWriter) error { + t := getTagTaggers(p, aliases) - return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { - return scene.AddTag(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { + return scene.AddTag(rw, otherID, subjectID) + }); err != nil { + return err + } + } + return nil } // TagImages searches for images whose path matches the provided tag name and tags the image with the tag. -func TagImages(p *models.Tag, paths []string, rw models.ImageReaderWriter) error { - t := getTagTagger(p) +func TagImages(p *models.Tag, paths []string, aliases []string, rw models.ImageReaderWriter) error { + t := getTagTaggers(p, aliases) - return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { - return image.AddTag(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return image.AddTag(rw, otherID, subjectID) + }); err != nil { + return err + } + } + return nil } // TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag. -func TagGalleries(p *models.Tag, paths []string, rw models.GalleryReaderWriter) error { - t := getTagTagger(p) +func TagGalleries(p *models.Tag, paths []string, aliases []string, rw models.GalleryReaderWriter) error { + t := getTagTaggers(p, aliases) - return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { - return gallery.AddTag(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return gallery.AddTag(rw, otherID, subjectID) + }); err != nil { + return err + } + } + return nil } diff --git a/pkg/autotag/tag_test.go b/pkg/autotag/tag_test.go index 7e70926cb..f23cab47b 100644 --- a/pkg/autotag/tag_test.go +++ b/pkg/autotag/tag_test.go @@ -8,35 +8,67 @@ import ( "github.com/stretchr/testify/assert" ) +type testTagCase struct { + tagName string + expectedRegex string + aliasName string + aliasRegex string +} + +var testTagCases = []testTagCase{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + "", + "", + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + "", + "", + }, + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + "alias name", + `(?i)(?:^|_|[^\w\d])alias[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + "alias + name", + `(?i)(?:^|_|[^\w\d])alias[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, +} + func TestTagScenes(t *testing.T) { - type test struct { - tagName string - expectedRegex string - } - - tagNames := []test{ - { - "tag name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - { - "tag + name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - } - - for _, p := range tagNames { - testTagScenes(t, p.tagName, p.expectedRegex) + for _, p := range testTagCases { + testTagScenes(t, p) } } -func testTagScenes(t *testing.T, tagName, expectedRegex string) { +func testTagScenes(t *testing.T, tc testTagCase) { + tagName := tc.tagName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex + mockSceneReader := &mocks.SceneReaderWriter{} const tagID = 2 + var aliases []string + + testPathName := tagName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + + matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") + var scenes []*models.Scene - matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -64,7 +96,23 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { PerPage: &perPage, } - mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(scenes, len(scenes), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.SceneFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockSceneReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + } for i := range matchingPaths { sceneID := i + 1 @@ -72,7 +120,7 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once() } - err := TagScenes(&tag, nil, mockSceneReader) + err := TagScenes(&tag, nil, aliases, mockSceneReader) assert := assert.New(t) @@ -81,34 +129,31 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { } func TestTagImages(t *testing.T) { - type test struct { - tagName string - expectedRegex string - } - - tagNames := []test{ - { - "tag name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - { - "tag + name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - } - - for _, p := range tagNames { - testTagImages(t, p.tagName, p.expectedRegex) + for _, p := range testTagCases { + testTagImages(t, p) } } -func testTagImages(t *testing.T, tagName, expectedRegex string) { +func testTagImages(t *testing.T, tc testTagCase) { + tagName := tc.tagName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex + mockImageReader := &mocks.ImageReaderWriter{} const tagID = 2 + var aliases []string + + testPathName := tagName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + var images []*models.Image - matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { images = append(images, &models.Image{ ID: i + 1, @@ -136,7 +181,23 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) { PerPage: &perPage, } - mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockImageReader.On("Query", expectedImageFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(images, len(images), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockImageReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(images, len(images), nil).Once() + } for i := range matchingPaths { imageID := i + 1 @@ -144,7 +205,7 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) { mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() } - err := TagImages(&tag, nil, mockImageReader) + err := TagImages(&tag, nil, aliases, mockImageReader) assert := assert.New(t) @@ -153,34 +214,31 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) { } func TestTagGalleries(t *testing.T) { - type test struct { - tagName string - expectedRegex string - } - - tagNames := []test{ - { - "tag name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - { - "tag + name", - `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, - }, - } - - for _, p := range tagNames { - testTagGalleries(t, p.tagName, p.expectedRegex) + for _, p := range testTagCases { + testTagGalleries(t, p) } } -func testTagGalleries(t *testing.T, tagName, expectedRegex string) { +func testTagGalleries(t *testing.T, tc testTagCase) { + tagName := tc.tagName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex + mockGalleryReader := &mocks.GalleryReaderWriter{} const tagID = 2 + var aliases []string + + testPathName := tagName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + var galleries []*models.Gallery - matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { galleries = append(galleries, &models.Gallery{ ID: i + 1, @@ -208,7 +266,23 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) { PerPage: &perPage, } - mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(galleries, len(galleries), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockGalleryReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + } for i := range matchingPaths { galleryID := i + 1 @@ -216,7 +290,7 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) { mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() } - err := TagGalleries(&tag, nil, mockGalleryReader) + err := TagGalleries(&tag, nil, aliases, mockGalleryReader) assert := assert.New(t) diff --git a/pkg/database/database.go b/pkg/database/database.go index 6d6af4039..4ee66e82e 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 23 +var appSchemaVersion uint = 24 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/24_tag_aliases.up.sql b/pkg/database/migrations/24_tag_aliases.up.sql new file mode 100644 index 000000000..115bdbe17 --- /dev/null +++ b/pkg/database/migrations/24_tag_aliases.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE `tag_aliases` ( + `tag_id` integer, + `alias` varchar(255) NOT NULL, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE +); + +CREATE UNIQUE INDEX `tag_aliases_alias_unique` on `tag_aliases` (`alias`); diff --git a/pkg/manager/filename_parser.go b/pkg/manager/filename_parser.go index b8c155b24..5be8ed178 100644 --- a/pkg/manager/filename_parser.go +++ b/pkg/manager/filename_parser.go @@ -10,6 +10,7 @@ import ( "time" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/tag" "github.com/jmoiron/sqlx" ) @@ -583,7 +584,7 @@ func (p *SceneFilenameParser) queryMovie(qb models.MovieReader, movieName string } func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *models.Tag { - // massage the performer name + // massage the tag name tagName = delimiterRE.ReplaceAllString(tagName, " ") // check cache first @@ -592,7 +593,12 @@ func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *mod } // match tag name exactly - ret, _ := qb.FindByName(tagName, true) + ret, _ := tag.ByName(qb, tagName) + + // try to match on alias + if ret == nil { + ret, _ = tag.ByAlias(qb, tagName) + } // add result to cache p.tagCache[tagName] = ret diff --git a/pkg/manager/jsonschema/tag.go b/pkg/manager/jsonschema/tag.go index 28833c4c5..66fa910ab 100644 --- a/pkg/manager/jsonschema/tag.go +++ b/pkg/manager/jsonschema/tag.go @@ -10,6 +10,7 @@ import ( type Tag struct { Name string `json:"name,omitempty"` + Aliases []string `json:"aliases,omitempty"` Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` diff --git a/pkg/manager/tag.go b/pkg/manager/tag.go deleted file mode 100644 index 8447b69f1..000000000 --- a/pkg/manager/tag.go +++ /dev/null @@ -1,21 +0,0 @@ -package manager - -import ( - "fmt" - - "github.com/stashapp/stash/pkg/models" -) - -func EnsureTagNameUnique(tag models.Tag, qb models.TagReader) error { - // ensure name is unique - sameNameTag, err := qb.FindByName(tag.Name, true) - if err != nil { - return err - } - - if sameNameTag != nil && tag.ID != sameNameTag.ID { - return fmt.Errorf("Tag with name '%s' already exists", tag.Name) - } - - return nil -} diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 884b8e2e1..6f577ffd3 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -276,13 +276,18 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa } if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { - if err := autotag.TagScenes(tag, paths, r.Scene()); err != nil { + aliases, err := r.Tag().GetAliases(tag.ID) + if err != nil { return err } - if err := autotag.TagImages(tag, paths, r.Image()); err != nil { + + if err := autotag.TagScenes(tag, paths, aliases, r.Scene()); err != nil { return err } - if err := autotag.TagGalleries(tag, paths, r.Gallery()); err != nil { + if err := autotag.TagImages(tag, paths, aliases, r.Image()); err != nil { + return err + } + if err := autotag.TagGalleries(tag, paths, aliases, r.Gallery()); err != nil { return err } diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 5d3c5cb6f..726416a99 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -243,6 +243,29 @@ func (_m *PerformerReaderWriter) FindBySceneID(sceneID int) ([]*models.Performer return r0, r1 } +// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint +func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + ret := _m.Called(hasStashID, stashboxEndpoint) + + var r0 []*models.Performer + if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok { + r0 = rf(hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(bool, string) error); ok { + r1 = rf(hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindMany provides a mock function with given fields: ids func (_m *PerformerReaderWriter) FindMany(ids []int) ([]*models.Performer, error) { ret := _m.Called(ids) @@ -498,26 +521,3 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error { return r0 } - -// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint -func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { - ret := _m.Called(hasStashID, stashboxEndpoint) - - var r0 []*models.Performer - if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok { - r0 = rf(hasStashID, stashboxEndpoint) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Performer) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(bool, string) error); ok { - r1 = rf(hasStashID, stashboxEndpoint) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index e0d765577..e6c90e932 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -314,6 +314,29 @@ func (_m *TagReaderWriter) FindMany(ids []int) ([]*models.Tag, error) { return r0, r1 } +// GetAliases provides a mock function with given fields: tagID +func (_m *TagReaderWriter) GetAliases(tagID int) ([]string, error) { + ret := _m.Called(tagID) + + var r0 []string + if rf, ok := ret.Get(0).(func(int) []string); ok { + r0 = rf(tagID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(tagID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: tagID func (_m *TagReaderWriter) GetImage(tagID int) ([]byte, error) { ret := _m.Called(tagID) @@ -390,8 +413,45 @@ func (_m *TagReaderWriter) QueryForAutoTag(words []string) ([]*models.Tag, error return r0, r1 } -// Update provides a mock function with given fields: updatedTag -func (_m *TagReaderWriter) Update(updatedTag models.Tag) (*models.Tag, error) { +// Update provides a mock function with given fields: updateTag +func (_m *TagReaderWriter) Update(updateTag models.TagPartial) (*models.Tag, error) { + ret := _m.Called(updateTag) + + var r0 *models.Tag + if rf, ok := ret.Get(0).(func(models.TagPartial) *models.Tag); ok { + r0 = rf(updateTag) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.TagPartial) error); ok { + r1 = rf(updateTag) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UpdateAliases provides a mock function with given fields: tagID, aliases +func (_m *TagReaderWriter) UpdateAliases(tagID int, aliases []string) error { + ret := _m.Called(tagID, aliases) + + var r0 error + if rf, ok := ret.Get(0).(func(int, []string) error); ok { + r0 = rf(tagID, aliases) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateFull provides a mock function with given fields: updatedTag +func (_m *TagReaderWriter) UpdateFull(updatedTag models.Tag) (*models.Tag, error) { ret := _m.Called(updatedTag) var r0 *models.Tag diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 7dc419b0b..c56a49120 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -9,6 +9,13 @@ type Tag struct { UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` } +type TagPartial struct { + ID int `db:"id" json:"id"` + Name *string `db:"name" json:"name"` // TODO make schema not null + CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` + UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` +} + func NewTag(name string) *Tag { currentTime := time.Now() return &Tag{ diff --git a/pkg/models/tag.go b/pkg/models/tag.go index a675bfbdf..a7f374a77 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -17,14 +17,17 @@ type TagReader interface { QueryForAutoTag(words []string) ([]*Tag, error) Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error) GetImage(tagID int) ([]byte, error) + GetAliases(tagID int) ([]string, error) } type TagWriter interface { Create(newTag Tag) (*Tag, error) - Update(updatedTag Tag) (*Tag, error) + Update(updateTag TagPartial) (*Tag, error) + UpdateFull(updatedTag Tag) (*Tag, error) Destroy(id int) error UpdateImage(tagID int, image []byte) error DestroyImage(tagID int) error + UpdateAliases(tagID int, aliases []string) error } type TagReaderWriter interface { diff --git a/pkg/scraper/matchers.go b/pkg/scraper/matchers.go index 3fcba3a48..cc1d6f99c 100644 --- a/pkg/scraper/matchers.go +++ b/pkg/scraper/matchers.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/tag" ) // MatchScrapedScenePerformer matches the provided performer with the @@ -66,18 +67,26 @@ func MatchScrapedSceneMovie(qb models.MovieReader, m *models.ScrapedSceneMovie) // MatchScrapedSceneTag matches the provided tag with the tags // in the database and sets the ID field if one is found. func MatchScrapedSceneTag(qb models.TagReader, s *models.ScrapedSceneTag) error { - tag, err := qb.FindByName(s.Name, true) + t, err := tag.ByName(qb, s.Name) if err != nil { return err } - if tag == nil { + if t == nil { + // try matching by alias + t, err = tag.ByAlias(qb, s.Name) + if err != nil { + return err + } + } + + if t == nil { // ignore - cannot match return nil } - id := strconv.Itoa(tag.ID) + id := strconv.Itoa(t.ID) s.ID = &id return nil } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d851fb7f0..f3a2f057a 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -479,3 +479,28 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp } } } + +// handler for StringCriterion for string list fields +type stringListCriterionHandlerBuilder struct { + // table joining primary and foreign objects + joinTable string + // string field on the join table + stringColumn string + + addJoinTable func(f *filterBuilder) +} + +func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if criterion != nil && len(criterion.Value) > 0 { + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + m.addJoinTable(f) + + stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(f) + } + } +} diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 568d9c30b..ead770459 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -343,6 +343,45 @@ func (r *imageRepository) replace(id int, image []byte) error { return err } +type stringRepository struct { + repository + stringColumn string +} + +func (r *stringRepository) get(id int) ([]string, error) { + query := fmt.Sprintf("SELECT %s from %s WHERE %s = ?", r.stringColumn, r.tableName, r.idColumn) + var ret []string + err := r.queryFunc(query, []interface{}{id}, func(rows *sqlx.Rows) error { + var out string + if err := rows.Scan(&out); err != nil { + return err + } + + ret = append(ret, out) + return nil + }) + return ret, err +} + +func (r *stringRepository) insert(id int, s string) (sql.Result, error) { + stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.stringColumn) + return r.tx.Exec(stmt, id, s) +} + +func (r *stringRepository) replace(id int, newStrings []string) error { + if err := r.destroy([]int{id}); err != nil { + return err + } + + for _, s := range newStrings { + if _, err := r.insert(id, s); err != nil { + return err + } + } + + return nil +} + type stashIDRepository struct { repository } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index d93a9302e..4f733af46 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -852,6 +852,12 @@ func createTags(tqb models.TagReaderWriter, n int, o int) error { return fmt.Errorf("Error creating tag %v+: %s", tag, err.Error()) } + // add alias + alias := getTagStringValue(i, "Alias") + if err := tqb.UpdateAliases(created.ID, []string{alias}); err != nil { + return fmt.Errorf("error setting tag alias: %s", err.Error()) + } + tagIDs = append(tagIDs, created.ID) tagNames = append(tagNames, created.Name) } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a4549acd9..811593efc 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -11,6 +11,8 @@ import ( const tagTable = "tags" const tagIDColumn = "tag_id" +const tagAliasesTable = "tag_aliases" +const tagAliasColumn = "alias" type tagQueryBuilder struct { repository @@ -35,7 +37,16 @@ func (qb *tagQueryBuilder) Create(newObject models.Tag) (*models.Tag, error) { return &ret, nil } -func (qb *tagQueryBuilder) Update(updatedObject models.Tag) (*models.Tag, error) { +func (qb *tagQueryBuilder) Update(updatedObject models.TagPartial) (*models.Tag, error) { + const partial = true + if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil { + return nil, err + } + + return qb.Find(updatedObject.ID) +} + +func (qb *tagQueryBuilder) UpdateFull(updatedObject models.Tag) (*models.Tag, error) { const partial = false if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil { return nil, err @@ -197,13 +208,19 @@ func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error // TODO - Query needs to be changed to support queries of this type, and // this method should be removed query := selectAll(tagTable) + query += " LEFT JOIN tag_aliases ON tag_aliases.tag_id = tags.id" var whereClauses []string var args []interface{} for _, w := range words { - whereClauses = append(whereClauses, "name like ?") - args = append(args, "%"+w+"%") + ww := "%" + w + "%" + whereClauses = append(whereClauses, "tags.name like ?") + args = append(args, ww) + + // include aliases + whereClauses = append(whereClauses, "tag_aliases.alias like ?") + args = append(args, ww) } where := strings.Join(whereClauses, " OR ") @@ -262,6 +279,9 @@ func (qb *tagQueryBuilder) makeFilter(tagFilter *models.TagFilterType) *filterBu // } // } + query.handleCriterionFunc(stringCriterionHandler(tagFilter.Name, tagTable+".name")) + query.handleCriterionFunc(tagAliasCriterionHandler(qb, tagFilter.Aliases)) + query.handleCriterionFunc(tagIsMissingCriterionHandler(qb, tagFilter.IsMissing)) query.handleCriterionFunc(tagSceneCountCriterionHandler(qb, tagFilter.SceneCount)) query.handleCriterionFunc(tagImageCountCriterionHandler(qb, tagFilter.ImageCount)) @@ -297,7 +317,8 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo // Disabling querying/sorting on marker count for now. if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"tags.name"} + query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id") + searchColumns := []string{"tags.name", "tag_aliases.alias"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) query.addWhere(clause) query.addArg(thisArgs...) @@ -328,6 +349,18 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo return tags, countResult, nil } +func tagAliasCriterionHandler(qb *tagQueryBuilder, alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: tagAliasesTable, + stringColumn: tagAliasColumn, + addJoinTable: func(f *filterBuilder) { + qb.aliasRepository().join(f, "", "tags.id") + }, + } + + return h.handler(alias) +} + func tagIsMissingCriterionHandler(qb *tagQueryBuilder, isMissing *string) criterionHandlerFunc { return func(f *filterBuilder) { if isMissing != nil && *isMissing != "" { @@ -484,3 +517,22 @@ func (qb *tagQueryBuilder) UpdateImage(tagID int, image []byte) error { func (qb *tagQueryBuilder) DestroyImage(tagID int) error { return qb.imageRepository().destroy([]int{tagID}) } + +func (qb *tagQueryBuilder) aliasRepository() *stringRepository { + return &stringRepository{ + repository: repository{ + tx: qb.tx, + tableName: tagAliasesTable, + idColumn: tagIDColumn, + }, + stringColumn: tagAliasColumn, + } +} + +func (qb *tagQueryBuilder) GetAliases(tagID int) ([]string, error) { + return qb.aliasRepository().get(tagID) +} + +func (qb *tagQueryBuilder) UpdateAliases(tagID int, aliases []string) error { + return qb.aliasRepository().replace(tagID, aliases) +} diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index f6a29def2..b0ccab2ac 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -83,8 +83,20 @@ func TestTagQueryForAutoTag(t *testing.T) { } assert.Len(t, tags, 2) - assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name)) - assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name)) + lcName := tagNames[tagIdxWithScene] + assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[0].Name)) + assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[1].Name)) + + // find by alias + name = getTagStringValue(tagIdxWithScene, "Alias") + tags, err = tqb.QueryForAutoTag([]string{name}) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithScene], tags[0].ID) return nil }) @@ -137,6 +149,100 @@ func TestTagFindByNames(t *testing.T) { }) } +func TestTagQueryName(t *testing.T) { + const tagIdx = 1 + tagName := getSceneStringValue(tagIdx, "Name") + + nameCriterion := &models.StringCriterionInput{ + Value: tagName, + Modifier: models.CriterionModifierEquals, + } + + tagFilter := &models.TagFilterType{ + Name: nameCriterion, + } + + verifyFn := func(tag *models.Tag, r models.Repository) { + verifyString(t, tag.Name, *nameCriterion) + } + + verifyTagQuery(t, tagFilter, nil, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagQuery(t, tagFilter, nil, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierMatchesRegex + nameCriterion.Value = "tag_.*1_Name" + verifyTagQuery(t, tagFilter, nil, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyTagQuery(t, tagFilter, nil, verifyFn) +} + +func TestTagQueryAlias(t *testing.T) { + const tagIdx = 1 + tagName := getSceneStringValue(tagIdx, "Alias") + + aliasCriterion := &models.StringCriterionInput{ + Value: tagName, + Modifier: models.CriterionModifierEquals, + } + + tagFilter := &models.TagFilterType{ + Aliases: aliasCriterion, + } + + verifyFn := func(tag *models.Tag, r models.Repository) { + aliases, err := r.Tag().GetAliases(tag.ID) + if err != nil { + t.Errorf("Error querying tags: %s", err.Error()) + } + + var alias string + if len(aliases) > 0 { + alias = aliases[0] + } + + verifyString(t, alias, *aliasCriterion) + } + + verifyTagQuery(t, tagFilter, nil, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagQuery(t, tagFilter, nil, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierMatchesRegex + aliasCriterion.Value = "tag_.*1_Alias" + verifyTagQuery(t, tagFilter, nil, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyTagQuery(t, tagFilter, nil, verifyFn) +} + +func verifyTagQuery(t *testing.T, tagFilter *models.TagFilterType, findFilter *models.FindFilterType, verifyFn func(t *models.Tag, r models.Repository)) { + withTxn(func(r models.Repository) error { + sqb := r.Tag() + + tags := queryTags(t, sqb, tagFilter, findFilter) + + for _, tag := range tags { + verifyFn(tag, r) + } + + return nil + }) +} + +func queryTags(t *testing.T, qb models.TagReader, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) []*models.Tag { + t.Helper() + tags, _, err := qb.Query(tagFilter, findFilter) + if err != nil { + t.Errorf("Error querying tags: %s", err.Error()) + } + + return tags +} + func TestTagQueryIsMissingImage(t *testing.T) { withTxn(func(r models.Repository) error { qb := r.Tag() @@ -461,6 +567,39 @@ func TestTagDestroyTagImage(t *testing.T) { } } +func TestTagUpdateAlias(t *testing.T) { + if err := withTxn(func(r models.Repository) error { + qb := r.Tag() + + // create tag to test against + const name = "TestTagUpdateAlias" + tag := models.Tag{ + Name: name, + } + created, err := qb.Create(tag) + if err != nil { + return fmt.Errorf("Error creating tag: %s", err.Error()) + } + + aliases := []string{"alias1", "alias2"} + err = qb.UpdateAliases(created.ID, aliases) + if err != nil { + return fmt.Errorf("Error updating tag aliases: %s", err.Error()) + } + + // ensure aliases set + storedAliases, err := qb.GetAliases(created.ID) + if err != nil { + return fmt.Errorf("Error getting aliases: %s", err.Error()) + } + assert.Equal(t, aliases, storedAliases) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + // TODO Create // TODO Update // TODO Destroy diff --git a/pkg/tag/export.go b/pkg/tag/export.go index 456bf68d8..ba9d6da82 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -16,6 +16,13 @@ func ToJSON(reader models.TagReader, tag *models.Tag) (*jsonschema.Tag, error) { UpdatedAt: models.JSONTime{Time: tag.UpdatedAt.Timestamp}, } + aliases, err := reader.GetAliases(tag.ID) + if err != nil { + return nil, fmt.Errorf("error getting tag aliases: %s", err.Error()) + } + + newTagJSON.Aliases = aliases + image, err := reader.GetImage(tag.ID) if err != nil { return nil, fmt.Errorf("error getting tag image: %s", err.Error()) diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 30c8448bc..16e85292d 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -16,6 +16,7 @@ const ( tagID = 1 noImageID = 2 errImageID = 3 + errAliasID = 4 ) const tagName = "testTag" @@ -36,9 +37,10 @@ func createTag(id int) models.Tag { } } -func createJSONTag(image string) *jsonschema.Tag { +func createJSONTag(aliases []string, image string) *jsonschema.Tag { return &jsonschema.Tag{ - Name: tagName, + Name: tagName, + Aliases: aliases, CreatedAt: models.JSONTime{ Time: createTime, }, @@ -59,21 +61,26 @@ var scenarios []testScenario func initTestTable() { scenarios = []testScenario{ - testScenario{ + { createTag(tagID), - createJSONTag("PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="), + createJSONTag([]string{"alias"}, "PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="), false, }, - testScenario{ + { createTag(noImageID), - createJSONTag(""), + createJSONTag(nil, ""), false, }, - testScenario{ + { createTag(errImageID), nil, true, }, + { + createTag(errAliasID), + nil, + true, + }, } } @@ -83,6 +90,12 @@ func TestToJSON(t *testing.T) { mockTagReader := &mocks.TagReaderWriter{} imageErr := errors.New("error getting image") + aliasErr := errors.New("error getting aliases") + + mockTagReader.On("GetAliases", tagID).Return([]string{"alias"}, nil).Once() + mockTagReader.On("GetAliases", noImageID).Return(nil, nil).Once() + mockTagReader.On("GetAliases", errImageID).Return(nil, nil).Once() + mockTagReader.On("GetAliases", errAliasID).Return(nil, aliasErr).Once() mockTagReader.On("GetImage", tagID).Return(models.DefaultTagImage, nil).Once() mockTagReader.On("GetImage", noImageID).Return(nil, nil).Once() diff --git a/pkg/tag/import.go b/pkg/tag/import.go index f050a4d5e..54de4bd0e 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -41,6 +41,10 @@ func (i *Importer) PostImport(id int) error { } } + if err := i.ReaderWriter.UpdateAliases(id, i.Input.Aliases); err != nil { + return fmt.Errorf("error setting tag aliases: %s", err.Error()) + } + return nil } @@ -76,7 +80,7 @@ func (i *Importer) Create() (*int, error) { func (i *Importer) Update(id int) error { tag := i.tag tag.ID = id - _, err := i.ReaderWriter.Update(tag) + _, err := i.ReaderWriter.UpdateFull(tag) if err != nil { return fmt.Errorf("error updating existing tag: %s", err.Error()) } diff --git a/pkg/tag/import_test.go b/pkg/tag/import_test.go index 3bcd829aa..ea29e47c3 100644 --- a/pkg/tag/import_test.go +++ b/pkg/tag/import_test.go @@ -56,12 +56,20 @@ func TestImporterPostImport(t *testing.T) { i := Importer{ ReaderWriter: readerWriter, - imageData: imageBytes, + Input: jsonschema.Tag{ + Aliases: []string{"alias"}, + }, + imageData: imageBytes, } updateTagImageErr := errors.New("UpdateImage error") + updateTagAliasErr := errors.New("UpdateAlias error") + + readerWriter.On("UpdateAliases", tagID, i.Input.Aliases).Return(nil).Once() + readerWriter.On("UpdateAliases", errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once() readerWriter.On("UpdateImage", tagID, imageBytes).Return(nil).Once() + readerWriter.On("UpdateImage", errAliasID, imageBytes).Return(nil).Once() readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateTagImageErr).Once() err := i.PostImport(tagID) @@ -70,6 +78,9 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(errImageID) assert.NotNil(t, err) + err = i.PostImport(errAliasID) + assert.NotNil(t, err) + readerWriter.AssertExpectations(t) } @@ -161,7 +172,7 @@ func TestUpdate(t *testing.T) { // id needs to be set for the mock input tag.ID = tagID - readerWriter.On("Update", tag).Return(nil, nil).Once() + readerWriter.On("UpdateFull", tag).Return(nil, nil).Once() err := i.Update(tagID) assert.Nil(t, err) @@ -170,7 +181,7 @@ func TestUpdate(t *testing.T) { // need to set id separately tagErr.ID = errImageID - readerWriter.On("Update", tagErr).Return(nil, errUpdate).Once() + readerWriter.On("UpdateFull", tagErr).Return(nil, errUpdate).Once() err = i.Update(errImageID) assert.NotNil(t, err) diff --git a/pkg/tag/query.go b/pkg/tag/query.go new file mode 100644 index 000000000..ce7406403 --- /dev/null +++ b/pkg/tag/query.go @@ -0,0 +1,51 @@ +package tag + +import "github.com/stashapp/stash/pkg/models" + +func ByName(qb models.TagReader, name string) (*models.Tag, error) { + f := &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: name, + Modifier: models.CriterionModifierEquals, + }, + } + + pp := 1 + ret, count, err := qb.Query(f, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, err + } + + if count > 0 { + return ret[0], nil + } + + return nil, nil +} + +func ByAlias(qb models.TagReader, alias string) (*models.Tag, error) { + f := &models.TagFilterType{ + Aliases: &models.StringCriterionInput{ + Value: alias, + Modifier: models.CriterionModifierEquals, + }, + } + + pp := 1 + ret, count, err := qb.Query(f, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, err + } + + if count > 0 { + return ret[0], nil + } + + return nil, nil +} diff --git a/pkg/tag/update.go b/pkg/tag/update.go new file mode 100644 index 000000000..4f5b9b18b --- /dev/null +++ b/pkg/tag/update.go @@ -0,0 +1,65 @@ +package tag + +import ( + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type NameExistsError struct { + Name string +} + +func (e *NameExistsError) Error() string { + return fmt.Sprintf("tag with name '%s' already exists", e.Name) +} + +type NameUsedByAliasError struct { + Name string + OtherTag string +} + +func (e *NameUsedByAliasError) Error() string { + return fmt.Sprintf("name '%s' is used as alias for '%s'", e.Name, e.OtherTag) +} + +// EnsureTagNameUnique returns an error if the tag name provided +// is used as a name or alias of another existing tag. +func EnsureTagNameUnique(id int, name string, qb models.TagReader) error { + // ensure name is unique + sameNameTag, err := ByName(qb, name) + if err != nil { + return err + } + + if sameNameTag != nil && id != sameNameTag.ID { + return &NameExistsError{ + Name: name, + } + } + + // query by alias + sameNameTag, err = ByAlias(qb, name) + if err != nil { + return err + } + + if sameNameTag != nil && id != sameNameTag.ID { + return &NameUsedByAliasError{ + Name: name, + OtherTag: sameNameTag.Name, + } + } + + return nil +} + +func EnsureAliasesUnique(id int, aliases []string, qb models.TagReader) error { + for _, a := range aliases { + if err := EnsureTagNameUnique(id, a, qb); err != nil { + return err + } + } + + return nil +} diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index fc42998d6..5edeee159 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412)) * Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393)) * Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379)) * Added Handy/Funscript support. ([#1377](https://github.com/stashapp/stash/pull/1377)) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index cd8f0f93f..5749ede0a 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -256,7 +256,7 @@ export const GalleryScrapeDialog: React.FC = ( const [createStudio] = useStudioCreate({ name: "" }); const [createPerformer] = usePerformerCreate(); - const [createTag] = useTagCreate({ name: "" }); + const [createTag] = useTagCreate(); const Toast = useToast(); @@ -337,7 +337,9 @@ export const GalleryScrapeDialog: React.FC = ( try { tagInput = Object.assign(tagInput, toCreate); const result = await createTag({ - variables: tagInput, + variables: { + input: tagInput, + }, }); // add the new tag to the new tags value diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index c4eb9fd80..72635f3c0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -88,7 +88,7 @@ export const PerformerEditPanel: React.FC = ({ const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); - const [createTag] = useTagCreate({ name: "" }); + const [createTag] = useTagCreate(); const genderOptions = [""].concat(getGenderStrings()); @@ -225,7 +225,9 @@ export const PerformerEditPanel: React.FC = ({ try { tagInput = Object.assign(tagInput, toCreate); const result = await createTag({ - variables: tagInput, + variables: { + input: tagInput, + }, }); if (!result.data?.tagCreate) { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 204a52811..4a84fd5b4 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -223,7 +223,7 @@ export const PerformerScrapeDialog: React.FC = ( new ScrapeResult(props.performer.details, props.scraped.details) ); - const [createTag] = useTagCreate({ name: "" }); + const [createTag] = useTagCreate(); const Toast = useToast(); interface IHasStoredID { @@ -318,7 +318,9 @@ export const PerformerScrapeDialog: React.FC = ( try { tagInput = Object.assign(tagInput, toCreate); const result = await createTag({ - variables: tagInput, + variables: { + input: tagInput, + }, }); // add the new tag to the new tags value diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index fd829e865..61de86382 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -319,7 +319,7 @@ export const SceneScrapeDialog: React.FC = ( const [createStudio] = useStudioCreate({ name: "" }); const [createPerformer] = usePerformerCreate(); const [createMovie] = useMovieCreate(); - const [createTag] = useTagCreate({ name: "" }); + const [createTag] = useTagCreate(); const Toast = useToast(); @@ -449,7 +449,9 @@ export const SceneScrapeDialog: React.FC = ( try { tagInput = Object.assign(tagInput, toCreate); const result = await createTag({ - variables: tagInput, + variables: { + input: tagInput, + }, }); // add the new tag to the new tags value diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 5858740ab..bd996017c 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -1,5 +1,12 @@ -import React, { useState } from "react"; -import Select, { ValueType, Styles } from "react-select"; +import React, { useEffect, useMemo, useState } from "react"; +import Select, { + ValueType, + Styles, + OptionProps, + components as reactSelectComponents, + GroupedOptionsType, + OptionsType, +} from "react-select"; import CreatableSelect from "react-select/creatable"; import { debounce } from "lodash"; @@ -16,6 +23,7 @@ import { } from "src/core/StashService"; import { useToast } from "src/hooks"; import { TextUtils } from "src/utils"; +import { SelectComponents } from "react-select/src/components"; export type ValidTypes = | GQL.SlimPerformerDataFragment @@ -59,6 +67,13 @@ interface ISelectProps { isMulti: T; isClearable?: boolean; onInputChange?: (input: string) => void; + components?: Partial>; + filterOption?: (option: Option, rawInput: string) => boolean; + isValidNewOption?: ( + inputValue: string, + value: ValueType, + options: OptionsType