mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
9b57fbbf50
commit
c70faa2a53
48 changed files with 1303 additions and 315 deletions
|
|
@ -12,10 +12,12 @@ fragment SceneMarkerData on SceneMarker {
|
||||||
primary_tag {
|
primary_tag {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
tags {
|
tags {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
aliases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
fragment SlimTagData on Tag {
|
fragment SlimTagData on Tag {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
aliases
|
||||||
image_path
|
image_path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ query AllTagsForFilter {
|
||||||
allTags {
|
allTags {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
aliases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
|
||||||
ID: existingTagID,
|
pp := 1
|
||||||
Name: existingTagName,
|
findFilter := &models.FindFilterType{
|
||||||
}, nil).Once()
|
PerPage: &pp,
|
||||||
tagRW.On("FindByName", errTagName, true).Return(nil, nil).Once()
|
}
|
||||||
|
|
||||||
|
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")
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
return scene.AddTag(rw, otherID, subjectID)
|
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.
|
// 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 {
|
||||||
return image.AddTag(rw, otherID, subjectID)
|
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.
|
// 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 {
|
||||||
return gallery.AddTag(rw, otherID, subjectID)
|
if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||||
})
|
return gallery.AddTag(rw, otherID, subjectID)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,35 +8,67 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestTagScenes(t *testing.T) {
|
||||||
type test struct {
|
for _, p := range testTagCases {
|
||||||
tagName string
|
testTagScenes(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 {
|
|
||||||
testTagScenes(t, p.tagName, p.expectedRegex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
7
pkg/database/migrations/24_tag_aliases.up.sql
Normal file
7
pkg/database/migrations/24_tag_aliases.up.sql
Normal 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`);
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,20 @@ func TestImporterPostImport(t *testing.T) {
|
||||||
|
|
||||||
i := Importer{
|
i := Importer{
|
||||||
ReaderWriter: readerWriter,
|
ReaderWriter: readerWriter,
|
||||||
imageData: imageBytes,
|
Input: jsonschema.Tag{
|
||||||
|
Aliases: []string{"alias"},
|
||||||
|
},
|
||||||
|
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
51
pkg/tag/query.go
Normal 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
65
pkg/tag/update.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,29 +33,34 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Group>
|
<div className={props.errors && "is-invalid"}>
|
||||||
{props.value &&
|
{props.value && props.value.length > 0 && (
|
||||||
props.value.map((v, i) => (
|
<Form.Group>
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
{props.value &&
|
||||||
<InputGroup className={props.className} key={i}>
|
props.value.map((v, i) => (
|
||||||
<Form.Control
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
className="text-input"
|
<InputGroup className={props.className} key={i}>
|
||||||
value={v}
|
<Form.Control
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
className="text-input"
|
||||||
valueChanged(i, e.currentTarget.value)
|
value={v}
|
||||||
}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
/>
|
valueChanged(i, e.currentTarget.value)
|
||||||
<InputGroup.Append>
|
}
|
||||||
<Button variant="danger" onClick={() => removeValue(i)}>
|
/>
|
||||||
<Icon icon="minus" />
|
<InputGroup.Append>
|
||||||
</Button>
|
<Button variant="danger" onClick={() => removeValue(i)}>
|
||||||
</InputGroup.Append>
|
<Icon icon="minus" />
|
||||||
</InputGroup>
|
</Button>
|
||||||
))}
|
</InputGroup.Append>
|
||||||
</Form.Group>
|
</InputGroup>
|
||||||
<Button className="minimal" onClick={() => addValue()}>
|
))}
|
||||||
<Icon icon="plus" />
|
</Form.Group>
|
||||||
</Button>
|
)}
|
||||||
|
<Button className="minimal" size="sm" onClick={() => addValue()}>
|
||||||
|
<Icon icon="plus" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="invalid-feedback">{props.errors}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,9 @@ export const useCreateTag = () => {
|
||||||
const handleCreate = (tag: string) =>
|
const handleCreate = (tag: string) =>
|
||||||
createTag({
|
createTag({
|
||||||
variables: {
|
variables: {
|
||||||
name: tag,
|
input: {
|
||||||
|
name: tag,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
update: (store, result) => {
|
update: (store, result) => {
|
||||||
if (!result.data?.tagCreate) return;
|
if (!result.data?.tagCreate) return;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
if (!isNew) {
|
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
|
||||||
return {
|
) {
|
||||||
id,
|
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
|
||||||
name,
|
...input,
|
||||||
image,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
image,
|
image,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
(ret as GQL.TagUpdateInput).id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSave() {
|
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 onClearImage() {
|
function renderImage() {
|
||||||
setImage(null);
|
let tagImage = tag?.image_path;
|
||||||
setImagePreview(
|
if (isEditing) {
|
||||||
tag?.image_path ? `${tag.image_path}&default=true` : undefined
|
if (image === null) {
|
||||||
);
|
tagImage = `${tagImage}&default=true`;
|
||||||
|
} else if (image) {
|
||||||
|
tagImage = image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagImage) {
|
||||||
|
return <img className="logo" alt={tag?.name ?? ""} src={tagImage} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ?? "",
|
<DetailsEditNavbar
|
||||||
isEditing: !!isEditing,
|
objectName={tag.name ?? "tag"}
|
||||||
onChange: setName,
|
isNew={isNew}
|
||||||
})}
|
isEditing={isEditing}
|
||||||
</tbody>
|
onToggleEdit={onToggleEdit}
|
||||||
</Table>
|
onSave={() => {}}
|
||||||
<DetailsEditNavbar
|
onImageChange={() => {}}
|
||||||
objectName={name ?? "tag"}
|
onClearImage={() => {}}
|
||||||
isNew={isNew}
|
onAutoTag={onAutoTag}
|
||||||
isEditing={isEditing}
|
onDelete={onDelete}
|
||||||
onToggleEdit={onToggleEdit}
|
/>
|
||||||
onSave={onSave}
|
</>
|
||||||
onImageChange={onImageChangeHandler}
|
) : (
|
||||||
onClearImage={() => {
|
<TagEditPanel
|
||||||
onClearImage();
|
tag={tag ?? undefined}
|
||||||
}}
|
onSubmit={onSave}
|
||||||
onAutoTag={onAutoTag}
|
onCancel={onToggleEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
acceptSVG
|
setImage={setImage}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isNew && tag && (
|
{!isNew && tag && (
|
||||||
<div className="col col-md-8">
|
<div className="col col-md-8">
|
||||||
|
|
|
||||||
30
ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx
Normal file
30
ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx
Normal 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()}</>;
|
||||||
|
};
|
||||||
164
ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx
Normal file
164
ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue