Tag aliases (#1412)

* Add Tag Update/UpdateFull
* Tag alias implementation
* Refactor tag page
* Add aliases in UI
* Include tag aliases in q filter
* Include aliases in tag select
* Add aliases to auto-tagger
* Use aliases in scraper
* Add tag aliases for filename parser
This commit is contained in:
WithoutPants 2021-05-26 14:36:05 +10:00 committed by GitHub
parent 9b57fbbf50
commit c70faa2a53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1303 additions and 315 deletions

View file

@ -12,10 +12,12 @@ fragment SceneMarkerData on SceneMarker {
primary_tag { primary_tag {
id id
name name
aliases
} }
tags { tags {
id id
name name
aliases
} }
} }

View file

@ -1,5 +1,6 @@
fragment SlimTagData on Tag { fragment SlimTagData on Tag {
id id
name name
aliases
image_path image_path
} }

View file

@ -1,6 +1,7 @@
fragment TagData on Tag { fragment TagData on Tag {
id id
name name
aliases
image_path image_path
scene_count scene_count
scene_marker_count scene_marker_count

View file

@ -1,5 +1,5 @@
mutation TagCreate($name: String!, $image: String) { mutation TagCreate($input: TagCreateInput!) {
tagCreate(input: { name: $name, image: $image }) { tagCreate(input: $input) {
...TagData ...TagData
} }
} }

View file

@ -33,6 +33,7 @@ query AllTagsForFilter {
allTags { allTags {
id id
name name
aliases
} }
} }

View file

@ -211,6 +211,12 @@ input TagFilterType {
OR: TagFilterType OR: TagFilterType
NOT: TagFilterType NOT: TagFilterType
"""Filter by tag name"""
name: StringCriterionInput
"""Filter by tag aliases"""
aliases: StringCriterionInput
"""Filter to only include tags missing this property""" """Filter to only include tags missing this property"""
is_missing: String is_missing: String

View file

@ -1,6 +1,7 @@
type Tag { type Tag {
id: ID! id: ID!
name: String! name: String!
aliases: [String!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@ -14,6 +15,7 @@ type Tag {
input TagCreateInput { input TagCreateInput {
name: String! name: String!
aliases: [String!]
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
@ -21,7 +23,8 @@ input TagCreateInput {
input TagUpdateInput { input TagUpdateInput {
id: ID! id: ID!
name: String! name: String
aliases: [String!]
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String

View file

@ -10,6 +10,17 @@ import (
"github.com/stashapp/stash/pkg/models" "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) { func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var count int var count int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {

View file

@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/tag"
"github.com/stashapp/stash/pkg/utils" "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 // Start the transaction and save the t
var tag *models.Tag var t *models.Tag
if err := r.withTxn(ctx, func(repo models.Repository) error { if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Tag() qb := repo.Tag()
// ensure name is unique // ensure name is unique
if err := manager.EnsureTagNameUnique(newTag, qb); err != nil { if err := tag.EnsureTagNameUnique(0, newTag.Name, qb); err != nil {
return err return err
} }
tag, err = qb.Create(newTag) t, err = qb.Create(newTag)
if err != nil { if err != nil {
return err return err
} }
// update image table // update image table
if len(imageData) > 0 { 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 return err
} }
} }
@ -58,7 +68,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
return nil, err return nil, err
} }
return tag, nil return t, nil
} }
func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdateInput) (*models.Tag, error) { 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 return nil, err
} }
updatedTag := models.Tag{
ID: tagID,
Name: input.Name,
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
}
var imageData []byte var imageData []byte
translator := changesetTranslator{ translator := changesetTranslator{
@ -90,39 +94,56 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
} }
// Start the transaction and save the tag // 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 { if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Tag() qb := repo.Tag()
// ensure name is unique // ensure name is unique
existing, err := qb.Find(tagID) t, err = qb.Find(tagID)
if err != nil { if err != nil {
return err return err
} }
if existing == nil { if t == nil {
return fmt.Errorf("Tag with ID %d not found", tagID) return fmt.Errorf("Tag with ID %d not found", tagID)
} }
if existing.Name != updatedTag.Name { updatedTag := models.TagPartial{
if err := manager.EnsureTagNameUnique(updatedTag, qb); err != nil { ID: tagID,
return err 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 { if err != nil {
return err return err
} }
// update image table // update image table
if len(imageData) > 0 { if len(imageData) > 0 {
if err := qb.UpdateImage(tag.ID, imageData); err != nil { if err := qb.UpdateImage(tagID, imageData); err != nil {
return err return err
} }
} else if imageIncluded { } else if imageIncluded {
// must be unsetting // 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 return err
} }
} }
@ -132,7 +153,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
return nil, err return nil, err
} }
return tag, nil return t, nil
} }
func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestroyInput) (bool, error) { func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestroyInput) (bool, error) {

View file

@ -30,11 +30,38 @@ func TestTagCreate(t *testing.T) {
r := newResolver() r := newResolver()
tagRW := r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter) tagRW := r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter)
tagRW.On("FindByName", existingTagName, true).Return(&models.Tag{
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, ID: existingTagID,
Name: existingTagName, Name: existingTagName,
}, nil).Once() },
tagRW.On("FindByName", errTagName, true).Return(nil, nil).Once() }, 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") expectedErr := errors.New("TagCreate error")
tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, expectedErr) tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, expectedErr)
@ -55,7 +82,8 @@ func TestTagCreate(t *testing.T) {
r = newResolver() r = newResolver()
tagRW = r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter) 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{ tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(&models.Tag{
ID: newTagID, ID: newTagID,
Name: tagName, Name: tagName,

View file

@ -459,7 +459,12 @@ func TestParseTagScenes(t *testing.T) {
for _, s := range tags { for _, s := range tags {
if err := withTxn(func(r models.Repository) error { 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 { }); err != nil {
t.Errorf("Error auto-tagging performers: %s", err) 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()) 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 { if scene.Title.String == scene.Path && len(tags) == 0 {
t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path) t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path)
} else if scene.Title.String != scene.Path && len(tags) > 0 { } else if scene.Title.String != scene.Path && len(tags) > 0 {
@ -604,7 +609,12 @@ func TestParseTagImages(t *testing.T) {
for _, s := range tags { for _, s := range tags {
if err := withTxn(func(r models.Repository) error { 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 { }); err != nil {
t.Errorf("Error auto-tagging performers: %s", err) t.Errorf("Error auto-tagging performers: %s", err)
} }
@ -749,7 +759,12 @@ func TestParseTagGalleries(t *testing.T) {
for _, s := range tags { for _, s := range tags {
if err := withTxn(func(r models.Repository) error { 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 { }); err != nil {
t.Errorf("Error auto-tagging performers: %s", err) t.Errorf("Error auto-tagging performers: %s", err)
} }

View file

@ -25,37 +25,62 @@ func getMatchingTags(path string, tagReader models.TagReader) ([]*models.Tag, er
return ret, nil return ret, nil
} }
func getTagTagger(p *models.Tag) tagger { func getTagTaggers(p *models.Tag, aliases []string) []tagger {
return tagger{ ret := []tagger{{
ID: p.ID, ID: p.ID,
Type: "tag", Type: "tag",
Name: p.Name, 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. // 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 { func TagScenes(p *models.Tag, paths []string, aliases []string, rw models.SceneReaderWriter) error {
t := getTagTagger(p) t := getTagTaggers(p, aliases)
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { for _, tt := range t {
if err := tt.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
return scene.AddTag(rw, otherID, subjectID) 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. // 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 { func TagImages(p *models.Tag, paths []string, aliases []string, rw models.ImageReaderWriter) error {
t := getTagTagger(p) t := getTagTaggers(p, aliases)
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { for _, tt := range t {
if err := tt.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
return image.AddTag(rw, otherID, subjectID) 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. // 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 { func TagGalleries(p *models.Tag, paths []string, aliases []string, rw models.GalleryReaderWriter) error {
t := getTagTagger(p) t := getTagTaggers(p, aliases)
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { for _, tt := range t {
if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
return gallery.AddTag(rw, otherID, subjectID) return gallery.AddTag(rw, otherID, subjectID)
}) }); err != nil {
return err
}
}
return nil
} }

View file

@ -8,35 +8,67 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestTagScenes(t *testing.T) { type testTagCase struct {
type test struct {
tagName string tagName string
expectedRegex string expectedRegex string
} aliasName string
aliasRegex string
}
tagNames := []test{ var testTagCases = []testTagCase{
{ {
"tag name", "tag name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
"",
"",
}, },
{ {
"tag + name", "tag + name",
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, `(?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])`,
},
}
for _, p := range tagNames { func TestTagScenes(t *testing.T) {
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{} mockSceneReader := &mocks.SceneReaderWriter{}
const tagID = 2 const tagID = 2
var aliases []string
testPathName := tagName
if aliasName != "" {
aliases = []string{aliasName}
testPathName = aliasName
}
matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
var scenes []*models.Scene var scenes []*models.Scene
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) { for i, p := range append(matchingPaths, falsePaths...) {
scenes = append(scenes, &models.Scene{ scenes = append(scenes, &models.Scene{
ID: i + 1, ID: i + 1,
@ -64,7 +96,23 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
PerPage: &perPage, 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 { for i := range matchingPaths {
sceneID := i + 1 sceneID := i + 1
@ -72,7 +120,7 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once() mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once()
} }
err := TagScenes(&tag, nil, mockSceneReader) err := TagScenes(&tag, nil, aliases, mockSceneReader)
assert := assert.New(t) assert := assert.New(t)
@ -81,34 +129,31 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
} }
func TestTagImages(t *testing.T) { func TestTagImages(t *testing.T) {
type test struct { for _, p := range testTagCases {
tagName string testTagImages(t, p)
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)
} }
} }
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{} mockImageReader := &mocks.ImageReaderWriter{}
const tagID = 2 const tagID = 2
var aliases []string
testPathName := tagName
if aliasName != "" {
aliases = []string{aliasName}
testPathName = aliasName
}
var images []*models.Image var images []*models.Image
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) { for i, p := range append(matchingPaths, falsePaths...) {
images = append(images, &models.Image{ images = append(images, &models.Image{
ID: i + 1, ID: i + 1,
@ -136,7 +181,23 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
PerPage: &perPage, 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 { for i := range matchingPaths {
imageID := i + 1 imageID := i + 1
@ -144,7 +205,7 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once()
} }
err := TagImages(&tag, nil, mockImageReader) err := TagImages(&tag, nil, aliases, mockImageReader)
assert := assert.New(t) assert := assert.New(t)
@ -153,34 +214,31 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
} }
func TestTagGalleries(t *testing.T) { func TestTagGalleries(t *testing.T) {
type test struct { for _, p := range testTagCases {
tagName string testTagGalleries(t, p)
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)
} }
} }
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{} mockGalleryReader := &mocks.GalleryReaderWriter{}
const tagID = 2 const tagID = 2
var aliases []string
testPathName := tagName
if aliasName != "" {
aliases = []string{aliasName}
testPathName = aliasName
}
var galleries []*models.Gallery var galleries []*models.Gallery
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
for i, p := range append(matchingPaths, falsePaths...) { for i, p := range append(matchingPaths, falsePaths...) {
galleries = append(galleries, &models.Gallery{ galleries = append(galleries, &models.Gallery{
ID: i + 1, ID: i + 1,
@ -208,7 +266,23 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
PerPage: &perPage, 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 { for i := range matchingPaths {
galleryID := i + 1 galleryID := i + 1
@ -216,7 +290,7 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once()
} }
err := TagGalleries(&tag, nil, mockGalleryReader) err := TagGalleries(&tag, nil, aliases, mockGalleryReader)
assert := assert.New(t) assert := assert.New(t)

View file

@ -23,7 +23,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var WriteMu *sync.Mutex var WriteMu *sync.Mutex
var dbPath string var dbPath string
var appSchemaVersion uint = 23 var appSchemaVersion uint = 24
var databaseSchemaVersion uint var databaseSchemaVersion uint
var ( var (

View file

@ -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`);

View file

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/tag"
"github.com/jmoiron/sqlx" "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 { func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *models.Tag {
// massage the performer name // massage the tag name
tagName = delimiterRE.ReplaceAllString(tagName, " ") tagName = delimiterRE.ReplaceAllString(tagName, " ")
// check cache first // check cache first
@ -592,7 +593,12 @@ func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *mod
} }
// match tag name exactly // 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 // add result to cache
p.tagCache[tagName] = ret p.tagCache[tagName] = ret

View file

@ -10,6 +10,7 @@ import (
type Tag struct { type Tag struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"`

View file

@ -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
}

View file

@ -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 := 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 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 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 return err
} }

View file

@ -243,6 +243,29 @@ func (_m *PerformerReaderWriter) FindBySceneID(sceneID int) ([]*models.Performer
return r0, r1 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 // FindMany provides a mock function with given fields: ids
func (_m *PerformerReaderWriter) FindMany(ids []int) ([]*models.Performer, error) { func (_m *PerformerReaderWriter) FindMany(ids []int) ([]*models.Performer, error) {
ret := _m.Called(ids) ret := _m.Called(ids)
@ -498,26 +521,3 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error {
return r0 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
}

View file

@ -314,6 +314,29 @@ func (_m *TagReaderWriter) FindMany(ids []int) ([]*models.Tag, error) {
return r0, r1 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 // GetImage provides a mock function with given fields: tagID
func (_m *TagReaderWriter) GetImage(tagID int) ([]byte, error) { func (_m *TagReaderWriter) GetImage(tagID int) ([]byte, error) {
ret := _m.Called(tagID) ret := _m.Called(tagID)
@ -390,8 +413,45 @@ func (_m *TagReaderWriter) QueryForAutoTag(words []string) ([]*models.Tag, error
return r0, r1 return r0, r1
} }
// Update provides a mock function with given fields: updatedTag // Update provides a mock function with given fields: updateTag
func (_m *TagReaderWriter) Update(updatedTag models.Tag) (*models.Tag, error) { 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) ret := _m.Called(updatedTag)
var r0 *models.Tag var r0 *models.Tag

View file

@ -9,6 +9,13 @@ type Tag struct {
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` 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 { func NewTag(name string) *Tag {
currentTime := time.Now() currentTime := time.Now()
return &Tag{ return &Tag{

View file

@ -17,14 +17,17 @@ type TagReader interface {
QueryForAutoTag(words []string) ([]*Tag, error) QueryForAutoTag(words []string) ([]*Tag, error)
Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error) Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error)
GetImage(tagID int) ([]byte, error) GetImage(tagID int) ([]byte, error)
GetAliases(tagID int) ([]string, error)
} }
type TagWriter interface { type TagWriter interface {
Create(newTag Tag) (*Tag, error) Create(newTag Tag) (*Tag, error)
Update(updatedTag Tag) (*Tag, error) Update(updateTag TagPartial) (*Tag, error)
UpdateFull(updatedTag Tag) (*Tag, error)
Destroy(id int) error Destroy(id int) error
UpdateImage(tagID int, image []byte) error UpdateImage(tagID int, image []byte) error
DestroyImage(tagID int) error DestroyImage(tagID int) error
UpdateAliases(tagID int, aliases []string) error
} }
type TagReaderWriter interface { type TagReaderWriter interface {

View file

@ -4,6 +4,7 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/tag"
) )
// MatchScrapedScenePerformer matches the provided performer with the // 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 // MatchScrapedSceneTag matches the provided tag with the tags
// in the database and sets the ID field if one is found. // in the database and sets the ID field if one is found.
func MatchScrapedSceneTag(qb models.TagReader, s *models.ScrapedSceneTag) error { 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 { if err != nil {
return err 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 // ignore - cannot match
return nil return nil
} }
id := strconv.Itoa(tag.ID) id := strconv.Itoa(t.ID)
s.ID = &id s.ID = &id
return nil return nil
} }

View file

@ -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)
}
}
}

View file

@ -343,6 +343,45 @@ func (r *imageRepository) replace(id int, image []byte) error {
return err 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 { type stashIDRepository struct {
repository repository
} }

View file

@ -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()) 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) tagIDs = append(tagIDs, created.ID)
tagNames = append(tagNames, created.Name) tagNames = append(tagNames, created.Name)
} }

View file

@ -11,6 +11,8 @@ import (
const tagTable = "tags" const tagTable = "tags"
const tagIDColumn = "tag_id" const tagIDColumn = "tag_id"
const tagAliasesTable = "tag_aliases"
const tagAliasColumn = "alias"
type tagQueryBuilder struct { type tagQueryBuilder struct {
repository repository
@ -35,7 +37,16 @@ func (qb *tagQueryBuilder) Create(newObject models.Tag) (*models.Tag, error) {
return &ret, nil 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 const partial = false
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil { if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
return nil, err 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 // TODO - Query needs to be changed to support queries of this type, and
// this method should be removed // this method should be removed
query := selectAll(tagTable) query := selectAll(tagTable)
query += " LEFT JOIN tag_aliases ON tag_aliases.tag_id = tags.id"
var whereClauses []string var whereClauses []string
var args []interface{} var args []interface{}
for _, w := range words { for _, w := range words {
whereClauses = append(whereClauses, "name like ?") ww := "%" + w + "%"
args = append(args, "%"+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 ") 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(tagIsMissingCriterionHandler(qb, tagFilter.IsMissing))
query.handleCriterionFunc(tagSceneCountCriterionHandler(qb, tagFilter.SceneCount)) query.handleCriterionFunc(tagSceneCountCriterionHandler(qb, tagFilter.SceneCount))
query.handleCriterionFunc(tagImageCountCriterionHandler(qb, tagFilter.ImageCount)) 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. // Disabling querying/sorting on marker count for now.
if q := findFilter.Q; q != nil && *q != "" { 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) clause, thisArgs := getSearchBinding(searchColumns, *q, false)
query.addWhere(clause) query.addWhere(clause)
query.addArg(thisArgs...) query.addArg(thisArgs...)
@ -328,6 +349,18 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
return tags, countResult, nil 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 { func tagIsMissingCriterionHandler(qb *tagQueryBuilder, isMissing *string) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
@ -484,3 +517,22 @@ func (qb *tagQueryBuilder) UpdateImage(tagID int, image []byte) error {
func (qb *tagQueryBuilder) DestroyImage(tagID int) error { func (qb *tagQueryBuilder) DestroyImage(tagID int) error {
return qb.imageRepository().destroy([]int{tagID}) 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)
}

View file

@ -83,8 +83,20 @@ func TestTagQueryForAutoTag(t *testing.T) {
} }
assert.Len(t, tags, 2) assert.Len(t, tags, 2)
assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name)) lcName := tagNames[tagIdxWithScene]
assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name)) 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 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) { func TestTagQueryIsMissingImage(t *testing.T) {
withTxn(func(r models.Repository) error { withTxn(func(r models.Repository) error {
qb := r.Tag() 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 Create
// TODO Update // TODO Update
// TODO Destroy // TODO Destroy

View file

@ -16,6 +16,13 @@ func ToJSON(reader models.TagReader, tag *models.Tag) (*jsonschema.Tag, error) {
UpdatedAt: models.JSONTime{Time: tag.UpdatedAt.Timestamp}, 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) image, err := reader.GetImage(tag.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting tag image: %s", err.Error()) return nil, fmt.Errorf("error getting tag image: %s", err.Error())

View file

@ -16,6 +16,7 @@ const (
tagID = 1 tagID = 1
noImageID = 2 noImageID = 2
errImageID = 3 errImageID = 3
errAliasID = 4
) )
const tagName = "testTag" 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{ return &jsonschema.Tag{
Name: tagName, Name: tagName,
Aliases: aliases,
CreatedAt: models.JSONTime{ CreatedAt: models.JSONTime{
Time: createTime, Time: createTime,
}, },
@ -59,21 +61,26 @@ var scenarios []testScenario
func initTestTable() { func initTestTable() {
scenarios = []testScenario{ scenarios = []testScenario{
testScenario{ {
createTag(tagID), createTag(tagID),
createJSONTag("PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="), createJSONTag([]string{"alias"}, "PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="),
false, false,
}, },
testScenario{ {
createTag(noImageID), createTag(noImageID),
createJSONTag(""), createJSONTag(nil, ""),
false, false,
}, },
testScenario{ {
createTag(errImageID), createTag(errImageID),
nil, nil,
true, true,
}, },
{
createTag(errAliasID),
nil,
true,
},
} }
} }
@ -83,6 +90,12 @@ func TestToJSON(t *testing.T) {
mockTagReader := &mocks.TagReaderWriter{} mockTagReader := &mocks.TagReaderWriter{}
imageErr := errors.New("error getting image") 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", tagID).Return(models.DefaultTagImage, nil).Once()
mockTagReader.On("GetImage", noImageID).Return(nil, nil).Once() mockTagReader.On("GetImage", noImageID).Return(nil, nil).Once()

View file

@ -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 return nil
} }
@ -76,7 +80,7 @@ func (i *Importer) Create() (*int, error) {
func (i *Importer) Update(id int) error { func (i *Importer) Update(id int) error {
tag := i.tag tag := i.tag
tag.ID = id tag.ID = id
_, err := i.ReaderWriter.Update(tag) _, err := i.ReaderWriter.UpdateFull(tag)
if err != nil { if err != nil {
return fmt.Errorf("error updating existing tag: %s", err.Error()) return fmt.Errorf("error updating existing tag: %s", err.Error())
} }

View file

@ -56,12 +56,20 @@ func TestImporterPostImport(t *testing.T) {
i := Importer{ i := Importer{
ReaderWriter: readerWriter, ReaderWriter: readerWriter,
Input: jsonschema.Tag{
Aliases: []string{"alias"},
},
imageData: imageBytes, imageData: imageBytes,
} }
updateTagImageErr := errors.New("UpdateImage error") 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", tagID, imageBytes).Return(nil).Once()
readerWriter.On("UpdateImage", errAliasID, imageBytes).Return(nil).Once()
readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateTagImageErr).Once() readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateTagImageErr).Once()
err := i.PostImport(tagID) err := i.PostImport(tagID)
@ -70,6 +78,9 @@ func TestImporterPostImport(t *testing.T) {
err = i.PostImport(errImageID) err = i.PostImport(errImageID)
assert.NotNil(t, err) assert.NotNil(t, err)
err = i.PostImport(errAliasID)
assert.NotNil(t, err)
readerWriter.AssertExpectations(t) readerWriter.AssertExpectations(t)
} }
@ -161,7 +172,7 @@ func TestUpdate(t *testing.T) {
// id needs to be set for the mock input // id needs to be set for the mock input
tag.ID = tagID tag.ID = tagID
readerWriter.On("Update", tag).Return(nil, nil).Once() readerWriter.On("UpdateFull", tag).Return(nil, nil).Once()
err := i.Update(tagID) err := i.Update(tagID)
assert.Nil(t, err) assert.Nil(t, err)
@ -170,7 +181,7 @@ func TestUpdate(t *testing.T) {
// need to set id separately // need to set id separately
tagErr.ID = errImageID tagErr.ID = errImageID
readerWriter.On("Update", tagErr).Return(nil, errUpdate).Once() readerWriter.On("UpdateFull", tagErr).Return(nil, errUpdate).Once()
err = i.Update(errImageID) err = i.Update(errImageID)
assert.NotNil(t, err) assert.NotNil(t, err)

51
pkg/tag/query.go Normal file
View file

@ -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
}

65
pkg/tag/update.go Normal file
View file

@ -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
}

View file

@ -1,4 +1,5 @@
### ✨ New Features ### ✨ 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)) * 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)) * 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)) * Added Handy/Funscript support. ([#1377](https://github.com/stashapp/stash/pull/1377))

View file

@ -256,7 +256,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
const [createStudio] = useStudioCreate({ name: "" }); const [createStudio] = useStudioCreate({ name: "" });
const [createPerformer] = usePerformerCreate(); const [createPerformer] = usePerformerCreate();
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate();
const Toast = useToast(); const Toast = useToast();
@ -337,7 +337,9 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
try { try {
tagInput = Object.assign(tagInput, toCreate); tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({ const result = await createTag({
variables: tagInput, variables: {
input: tagInput,
},
}); });
// add the new tag to the new tags value // add the new tag to the new tags value

View file

@ -88,7 +88,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate();
const genderOptions = [""].concat(getGenderStrings()); const genderOptions = [""].concat(getGenderStrings());
@ -225,7 +225,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
try { try {
tagInput = Object.assign(tagInput, toCreate); tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({ const result = await createTag({
variables: tagInput, variables: {
input: tagInput,
},
}); });
if (!result.data?.tagCreate) { if (!result.data?.tagCreate) {

View file

@ -223,7 +223,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
new ScrapeResult<string>(props.performer.details, props.scraped.details) new ScrapeResult<string>(props.performer.details, props.scraped.details)
); );
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate();
const Toast = useToast(); const Toast = useToast();
interface IHasStoredID { interface IHasStoredID {
@ -318,7 +318,9 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
try { try {
tagInput = Object.assign(tagInput, toCreate); tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({ const result = await createTag({
variables: tagInput, variables: {
input: tagInput,
},
}); });
// add the new tag to the new tags value // add the new tag to the new tags value

View file

@ -319,7 +319,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
const [createStudio] = useStudioCreate({ name: "" }); const [createStudio] = useStudioCreate({ name: "" });
const [createPerformer] = usePerformerCreate(); const [createPerformer] = usePerformerCreate();
const [createMovie] = useMovieCreate(); const [createMovie] = useMovieCreate();
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate();
const Toast = useToast(); const Toast = useToast();
@ -449,7 +449,9 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
try { try {
tagInput = Object.assign(tagInput, toCreate); tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({ const result = await createTag({
variables: tagInput, variables: {
input: tagInput,
},
}); });
// add the new tag to the new tags value // add the new tag to the new tags value

View file

@ -1,5 +1,12 @@
import React, { useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import Select, { ValueType, Styles } from "react-select"; import Select, {
ValueType,
Styles,
OptionProps,
components as reactSelectComponents,
GroupedOptionsType,
OptionsType,
} from "react-select";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import { debounce } from "lodash"; import { debounce } from "lodash";
@ -16,6 +23,7 @@ import {
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { SelectComponents } from "react-select/src/components";
export type ValidTypes = export type ValidTypes =
| GQL.SlimPerformerDataFragment | GQL.SlimPerformerDataFragment
@ -59,6 +67,13 @@ interface ISelectProps<T extends boolean> {
isMulti: T; isMulti: T;
isClearable?: boolean; isClearable?: boolean;
onInputChange?: (input: string) => void; onInputChange?: (input: string) => void;
components?: Partial<SelectComponents<Option, T>>;
filterOption?: (option: Option, rawInput: string) => boolean;
isValidNewOption?: (
inputValue: string,
value: ValueType<Option, T>,
options: OptionsType<Option> | GroupedOptionsType<Option>
) => boolean;
placeholder?: string; placeholder?: string;
showDropdown?: boolean; showDropdown?: boolean;
groupHeader?: string; groupHeader?: string;
@ -109,6 +124,9 @@ const SelectComponent = <T extends boolean>({
creatable = false, creatable = false,
isMulti, isMulti,
onInputChange, onInputChange,
filterOption,
isValidNewOption,
components,
placeholder, placeholder,
showDropdown = true, showDropdown = true,
groupHeader, groupHeader,
@ -158,12 +176,15 @@ const SelectComponent = <T extends boolean>({
noOptionsMessage: () => noOptionsMessage, noOptionsMessage: () => noOptionsMessage,
placeholder: isDisabled ? "" : placeholder, placeholder: isDisabled ? "" : placeholder,
onInputChange, onInputChange,
filterOption,
isValidNewOption,
isDisabled, isDisabled,
isLoading, isLoading,
styles, styles,
closeMenuOnSelect, closeMenuOnSelect,
menuPortalTarget, menuPortalTarget,
components: { components: {
...components,
IndicatorSeparator: () => null, IndicatorSeparator: () => null,
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),
...(isDisabled && { MultiValueRemove: () => null }), ...(isDisabled && { MultiValueRemove: () => null }),
@ -454,22 +475,108 @@ export const MovieSelect: React.FC<IFilterProps> = (props) => {
}; };
export const TagSelect: React.FC<IFilterProps> = (props) => { export const TagSelect: React.FC<IFilterProps> = (props) => {
const [tagAliases, setTagAliases] = useState<Record<string, string[]>>({});
const [allAliases, setAllAliases] = useState<string[]>([]);
const { data, loading } = useAllTagsForFilter(); const { data, loading } = useAllTagsForFilter();
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate();
const placeholder = props.noSelectionString ?? "Select tags..."; const placeholder = props.noSelectionString ?? "Select tags...";
const tags = data?.allTags ?? []; const tags = useMemo(() => data?.allTags ?? [], [data?.allTags]);
useEffect(() => {
// build the tag aliases map
const newAliases: Record<string, string[]> = {};
const newAll: string[] = [];
tags.forEach((t) => {
newAliases[t.id] = t.aliases;
newAll.push(...t.aliases);
});
setTagAliases(newAliases);
setAllAliases(newAll);
}, [tags]);
const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {
const { inputValue } = optionProps.selectProps;
let thisOptionProps = optionProps;
if (
inputValue &&
!optionProps.label.toLowerCase().includes(inputValue.toLowerCase())
) {
// must be alias
const newLabel = `${optionProps.data.label} (alias)`;
thisOptionProps = {
...optionProps,
children: newLabel,
};
}
return <reactSelectComponents.Option {...thisOptionProps} />;
};
const filterOption = (option: Option, rawInput: string): boolean => {
if (!rawInput) {
return true;
}
const input = rawInput.toLowerCase();
const optionVal = option.label.toLowerCase();
if (optionVal.includes(input)) {
return true;
}
// search for tag aliases
const aliases = tagAliases[option.value];
// only match on alias if exact
if (aliases && aliases.some((a) => a.toLowerCase() === input)) {
return true;
}
return false;
};
const onCreate = async (name: string) => { const onCreate = async (name: string) => {
const result = await createTag({ const result = await createTag({
variables: { name }, variables: {
input: {
name,
},
},
}); });
return { item: result.data!.tagCreate!, message: "Created tag" }; return { item: result.data!.tagCreate!, message: "Created tag" };
}; };
const isValidNewOption = (
inputValue: string,
value: ValueType<Option, boolean>,
options: OptionsType<Option> | GroupedOptionsType<Option>
) => {
if (!inputValue) {
return false;
}
if (
(options as OptionsType<Option>).some((o: Option) => {
return o.label.toLowerCase() === inputValue.toLowerCase();
})
) {
return false;
}
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
return false;
}
return true;
};
return ( return (
<FilterSelectComponent <FilterSelectComponent
{...props} {...props}
filterOption={filterOption}
isValidNewOption={isValidNewOption}
components={{ Option: TagOption }}
isMulti={props.isMulti ?? false} isMulti={props.isMulti ?? false}
items={tags} items={tags}
creatable={props.creatable ?? true} creatable={props.creatable ?? true}

View file

@ -7,6 +7,7 @@ interface IStringListInputProps {
setValue: (value: string[]) => void; setValue: (value: string[]) => void;
defaultNewValue?: string; defaultNewValue?: string;
className?: string; className?: string;
errors?: string;
} }
export const StringListInput: React.FC<IStringListInputProps> = (props) => { export const StringListInput: React.FC<IStringListInputProps> = (props) => {
@ -32,6 +33,8 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
return ( return (
<> <>
<div className={props.errors && "is-invalid"}>
{props.value && props.value.length > 0 && (
<Form.Group> <Form.Group>
{props.value && {props.value &&
props.value.map((v, i) => ( props.value.map((v, i) => (
@ -52,9 +55,12 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
</InputGroup> </InputGroup>
))} ))}
</Form.Group> </Form.Group>
<Button className="minimal" onClick={() => addValue()}> )}
<Button className="minimal" size="sm" onClick={() => addValue()}>
<Icon icon="plus" /> <Icon icon="plus" />
</Button> </Button>
</div>
<div className="invalid-feedback">{props.errors}</div>
</> </>
); );
}; };

View file

@ -269,8 +269,10 @@ export const useCreateTag = () => {
const handleCreate = (tag: string) => const handleCreate = (tag: string) =>
createTag({ createTag({
variables: { variables: {
input: {
name: tag, name: tag,
}, },
},
update: (store, result) => { update: (store, result) => {
if (!result.data?.tagCreate) return; if (!result.data?.tagCreate) return;

View file

@ -1,4 +1,4 @@
import { Table, Tabs, Tab } from "react-bootstrap"; import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
@ -12,7 +12,7 @@ import {
useTagDestroy, useTagDestroy,
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils } from "src/utils";
import { import {
DetailsEditNavbar, DetailsEditNavbar,
Modal, Modal,
@ -24,6 +24,8 @@ import { TagMarkersPanel } from "./TagMarkersPanel";
import { TagImagesPanel } from "./TagImagesPanel"; import { TagImagesPanel } from "./TagImagesPanel";
import { TagPerformersPanel } from "./TagPerformersPanel"; import { TagPerformersPanel } from "./TagPerformersPanel";
import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel";
import { TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel";
interface ITabParams { interface ITabParams {
id?: string; id?: string;
@ -42,16 +44,14 @@ export const Tag: React.FC = () => {
// Editing tag state // Editing tag state
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [name, setName] = useState<string>();
// Tag state // Tag state
const [tag, setTag] = useState<GQL.TagDataFragment | undefined>();
const [imagePreview, setImagePreview] = useState<string>();
const { data, error, loading } = useFindTag(id); const { data, error, loading } = useFindTag(id);
const tag = data?.findTag;
const [updateTag] = useTagUpdate(); const [updateTag] = useTagUpdate();
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput); const [createTag] = useTagCreate();
const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput); const [deleteTag] = useTagDestroy({ id });
const activeTabKey = const activeTabKey =
tab === "markers" || tab === "markers" ||
@ -69,10 +69,6 @@ export const Tag: React.FC = () => {
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
if (isEditing) {
Mousetrap.bind("s s", () => onSave());
}
Mousetrap.bind("e", () => setIsEditing(true)); Mousetrap.bind("e", () => setIsEditing(true));
Mousetrap.bind("d d", () => onDelete()); Mousetrap.bind("d d", () => onDelete());
@ -86,28 +82,13 @@ export const Tag: React.FC = () => {
}; };
}); });
function updateTagEditState(state: GQL.TagDataFragment) {
setName(state.name);
}
function updateTagData(tagData: GQL.TagDataFragment) {
setImage(undefined);
updateTagEditState(tagData);
setImagePreview(tagData.image_path ?? undefined);
setTag(tagData);
}
useEffect(() => { useEffect(() => {
if (data && data.findTag) { if (data && data.findTag) {
setImage(undefined); setImage(undefined);
updateTagEditState(data.findTag);
setImagePreview(data.findTag.image_path ?? undefined);
setTag(data.findTag);
} }
}, [data]); }, [data]);
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
setImagePreview(imageData);
setImage(imageData); setImage(imageData);
} }
@ -118,37 +99,44 @@ export const Tag: React.FC = () => {
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
} }
function getTagInput() { function getTagInput(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...input,
image,
};
if (!isNew) { if (!isNew) {
return { (ret as GQL.TagUpdateInput).id = id;
id,
name,
image,
};
}
return {
name,
image,
};
} }
async function onSave() { return ret;
}
async function onSave(
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) {
try { try {
if (!isNew) { if (!isNew) {
const result = await updateTag({ const result = await updateTag({
variables: { variables: {
input: getTagInput() as GQL.TagUpdateInput, input: getTagInput(input) as GQL.TagUpdateInput,
}, },
}); });
if (result.data?.tagUpdate) { if (result.data?.tagUpdate) {
updateTagData(result.data.tagUpdate);
setIsEditing(false); setIsEditing(false);
return result.data.tagUpdate.id;
} }
} else { } else {
const result = await createTag(); const result = await createTag({
variables: {
input: getTagInput(input) as GQL.TagCreateInput,
},
});
if (result.data?.tagCreate?.id) { if (result.data?.tagCreate?.id) {
history.push(`/tags/${result.data.tagCreate.id}`);
setIsEditing(false); setIsEditing(false);
return result.data.tagCreate.id;
} }
} }
} catch (e) { } catch (e) {
@ -177,10 +165,6 @@ export const Tag: React.FC = () => {
history.push(`/tags`); history.push(`/tags`);
} }
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function renderDeleteAlert() { function renderDeleteAlert() {
return ( return (
<Modal <Modal
@ -189,23 +173,29 @@ export const Tag: React.FC = () => {
accept={{ text: "Delete", variant: "danger", onClick: onDelete }} accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
> >
<p>Are you sure you want to delete {name ?? "tag"}?</p> <p>Are you sure you want to delete {tag?.name ?? "tag"}?</p>
</Modal> </Modal>
); );
} }
function onToggleEdit() { function onToggleEdit() {
setIsEditing(!isEditing); setIsEditing(!isEditing);
if (tag) { setImage(undefined);
updateTagData(tag); }
function renderImage() {
let tagImage = tag?.image_path;
if (isEditing) {
if (image === null) {
tagImage = `${tagImage}&default=true`;
} else if (image) {
tagImage = image;
} }
} }
function onClearImage() { if (tagImage) {
setImage(null); return <img className="logo" alt={tag?.name ?? ""} src={tagImage} />;
setImagePreview( }
tag?.image_path ? `${tag.image_path}&default=true` : undefined
);
} }
return ( return (
@ -216,38 +206,39 @@ export const Tag: React.FC = () => {
"col-8": isNew, "col-8": isNew,
})} })}
> >
{isNew && <h2>Add Tag</h2>} <div className="text-center logo-container">
<div className="text-center">
{imageEncoding ? ( {imageEncoding ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
<img className="logo" alt={name} src={imagePreview} /> renderImage()
)} )}
{!isNew && tag && <h2>{tag.name}</h2>}
</div> </div>
<Table> {!isEditing && !isNew && tag ? (
<tbody> <>
{TableUtils.renderInputGroup({ <TagDetailsPanel tag={tag} />
title: "Name", {/* HACK - this is also rendered in the TagEditPanel */}
value: name ?? "",
isEditing: !!isEditing,
onChange: setName,
})}
</tbody>
</Table>
<DetailsEditNavbar <DetailsEditNavbar
objectName={name ?? "tag"} objectName={tag.name ?? "tag"}
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onToggleEdit} onToggleEdit={onToggleEdit}
onSave={onSave} onSave={() => {}}
onImageChange={onImageChangeHandler} onImageChange={() => {}}
onClearImage={() => { onClearImage={() => {}}
onClearImage();
}}
onAutoTag={onAutoTag} onAutoTag={onAutoTag}
onDelete={onDelete} onDelete={onDelete}
acceptSVG
/> />
</>
) : (
<TagEditPanel
tag={tag ?? undefined}
onSubmit={onSave}
onCancel={onToggleEdit}
onDelete={onDelete}
setImage={setImage}
/>
)}
</div> </div>
{!isNew && tag && ( {!isNew && tag && (
<div className="col col-md-8"> <div className="col col-md-8">

View file

@ -0,0 +1,30 @@
import React from "react";
import { Badge } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
interface ITagDetails {
tag: Partial<GQL.TagDataFragment>;
}
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
function renderAliasesField() {
if (!tag.aliases?.length) {
return;
}
return (
<dl className="row">
<dt className="col-3 col-xl-2">Aliases</dt>
<dd className="col-9 col-xl-10">
{tag.aliases.map((a) => (
<Badge className="tag-item" variant="secondary">
{a}
</Badge>
))}
</dd>
</dl>
);
}
return <>{renderAliasesField()}</>;
};

View file

@ -0,0 +1,164 @@
import React, { useEffect } from "react";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import { DetailsEditNavbar } from "src/components/Shared";
import { Form, Col, Row } from "react-bootstrap";
import { ImageUtils } from "src/utils";
import { useFormik } from "formik";
import { Prompt, useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import { StringListInput } from "src/components/Shared/StringListInput";
interface ITagEditPanel {
tag?: Partial<GQL.TagDataFragment>;
// returns id
onSubmit: (
tag: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
) => Promise<string | undefined>;
onCancel: () => void;
onDelete: () => void;
setImage: (image?: string | null) => void;
}
export const TagEditPanel: React.FC<ITagEditPanel> = ({
tag,
onSubmit,
onCancel,
onDelete,
setImage,
}) => {
const history = useHistory();
const isNew = tag === undefined;
const labelXS = 3;
const labelXL = 3;
const fieldXS = 9;
const fieldXL = 9;
const schema = yup.object({
name: yup.string().required(),
aliases: yup
.array(yup.string().required())
.optional()
.test({
name: "unique",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test: (value: any) => {
return (value ?? []).length === new Set(value).size;
},
message: "aliases must be unique",
}),
});
const initialValues = {
name: tag?.name,
aliases: tag?.aliases,
};
type InputValues = typeof initialValues;
const formik = useFormik({
initialValues,
validationSchema: schema,
enableReinitialize: true,
onSubmit: doSubmit,
});
async function doSubmit(values: InputValues) {
const id = await onSubmit(getTagInput(values));
if (id) {
formik.resetForm({ values });
history.push(`/tags/${id}`);
}
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("s s", () => formik.handleSubmit());
return () => {
Mousetrap.unbind("s s");
};
});
function getTagInput(values: InputValues) {
const input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
...values,
};
if (tag && tag.id) {
(input as GQL.TagUpdateInput).id = tag.id;
}
return input;
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, setImage);
}
const isEditing = true;
// TODO: CSS class
return (
<div>
{isNew && <h2>Add Tag</h2>}
<Prompt
when={formik.dirty}
message={(location) => {
if (!isNew && location.pathname.startsWith(`/tags/${tag?.id}`)) {
return true;
}
return "Unsaved changes. Are you sure you want to leave?";
}}
/>
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
<Form.Group controlId="name" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
Name
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<Form.Control
className="text-input"
placeholder="Name"
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={labelXS} xl={labelXL}>
Aliases
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<StringListInput
value={formik.values.aliases ?? []}
setValue={(value) => formik.setFieldValue("aliases", value)}
errors={formik.errors.aliases}
/>
</Col>
</Form.Group>
</Form>
<DetailsEditNavbar
objectName={tag?.name ?? "tag"}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()}
onImageChange={onImageChange}
onImageChangeURL={setImage}
onClearImage={() => {
setImage(null);
}}
onDelete={onDelete}
/>
</div>
);
};

View file

@ -31,8 +31,11 @@
.tag-details { .tag-details {
.logo { .logo {
margin-bottom: 4rem;
max-height: 50vh; max-height: 50vh;
max-width: 100%; max-width: 100%;
} }
.logo-container {
margin-bottom: 4rem;
}
} }

View file

@ -651,9 +651,8 @@ export const tagMutationImpactedQueries = [
GQL.FindTagsDocument, GQL.FindTagsDocument,
]; ];
export const useTagCreate = (input: GQL.TagCreateInput) => export const useTagCreate = () =>
GQL.useTagCreateMutation({ GQL.useTagCreateMutation({
variables: input,
refetchQueries: getQueryNames([ refetchQueries: getQueryNames([
GQL.AllTagsDocument, GQL.AllTagsDocument,
GQL.AllTagsForFilterDocument, GQL.AllTagsForFilterDocument,