mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +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 {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
}
|
||||
|
||||
tags {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
fragment SlimTagData on Tag {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
image_path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
fragment TagData on Tag {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
image_path
|
||||
scene_count
|
||||
scene_marker_count
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
mutation TagCreate($name: String!, $image: String) {
|
||||
tagCreate(input: { name: $name, image: $image }) {
|
||||
mutation TagCreate($input: TagCreateInput!) {
|
||||
tagCreate(input: $input) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ query AllTagsForFilter {
|
|||
allTags {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,12 @@ input TagFilterType {
|
|||
OR: TagFilterType
|
||||
NOT: TagFilterType
|
||||
|
||||
"""Filter by tag name"""
|
||||
name: StringCriterionInput
|
||||
|
||||
"""Filter by tag aliases"""
|
||||
aliases: StringCriterionInput
|
||||
|
||||
"""Filter to only include tags missing this property"""
|
||||
is_missing: String
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
aliases: [String!]!
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ type Tag {
|
|||
|
||||
input TagCreateInput {
|
||||
name: String!
|
||||
aliases: [String!]
|
||||
|
||||
"""This should be a URL or a base64 encoded data URL"""
|
||||
image: String
|
||||
|
|
@ -21,7 +23,8 @@ input TagCreateInput {
|
|||
|
||||
input TagUpdateInput {
|
||||
id: ID!
|
||||
name: String!
|
||||
name: String
|
||||
aliases: [String!]
|
||||
|
||||
"""This should be a URL or a base64 encoded data URL"""
|
||||
image: String
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ import (
|
|||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
ret, err = repo.Tag().GetAliases(obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
|
||||
var count int
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/manager"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/tag"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -31,24 +31,34 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
|||
}
|
||||
}
|
||||
|
||||
// Start the transaction and save the tag
|
||||
var tag *models.Tag
|
||||
// Start the transaction and save the t
|
||||
var t *models.Tag
|
||||
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||
qb := repo.Tag()
|
||||
|
||||
// ensure name is unique
|
||||
if err := manager.EnsureTagNameUnique(newTag, qb); err != nil {
|
||||
if err := tag.EnsureTagNameUnique(0, newTag.Name, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err = qb.Create(newTag)
|
||||
t, err = qb.Create(newTag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(tag.ID, imageData); err != nil {
|
||||
if err := qb.UpdateImage(t.ID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(input.Aliases) > 0 {
|
||||
if err := tag.EnsureAliasesUnique(t.ID, input.Aliases, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.UpdateAliases(t.ID, input.Aliases); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +68,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdateInput) (*models.Tag, error) {
|
||||
|
|
@ -68,12 +78,6 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
|||
return nil, err
|
||||
}
|
||||
|
||||
updatedTag := models.Tag{
|
||||
ID: tagID,
|
||||
Name: input.Name,
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
|
||||
}
|
||||
|
||||
var imageData []byte
|
||||
|
||||
translator := changesetTranslator{
|
||||
|
|
@ -90,39 +94,56 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
|||
}
|
||||
|
||||
// Start the transaction and save the tag
|
||||
var tag *models.Tag
|
||||
var t *models.Tag
|
||||
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||
qb := repo.Tag()
|
||||
|
||||
// ensure name is unique
|
||||
existing, err := qb.Find(tagID)
|
||||
t, err = qb.Find(tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if t == nil {
|
||||
return fmt.Errorf("Tag with ID %d not found", tagID)
|
||||
}
|
||||
|
||||
if existing.Name != updatedTag.Name {
|
||||
if err := manager.EnsureTagNameUnique(updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
updatedTag := models.TagPartial{
|
||||
ID: tagID,
|
||||
UpdatedAt: &models.SQLiteTimestamp{Timestamp: time.Now()},
|
||||
}
|
||||
|
||||
tag, err = qb.Update(updatedTag)
|
||||
if input.Name != nil && t.Name != *input.Name {
|
||||
if err := tag.EnsureTagNameUnique(tagID, *input.Name, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedTag.Name = input.Name
|
||||
}
|
||||
|
||||
t, err = qb.Update(updatedTag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(tag.ID, imageData); err != nil {
|
||||
if err := qb.UpdateImage(tagID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if imageIncluded {
|
||||
// must be unsetting
|
||||
if err := qb.DestroyImage(tag.ID); err != nil {
|
||||
if err := qb.DestroyImage(tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("aliases") {
|
||||
if err := tag.EnsureAliasesUnique(tagID, input.Aliases, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.UpdateAliases(tagID, input.Aliases); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -132,7 +153,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestroyInput) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -30,11 +30,38 @@ func TestTagCreate(t *testing.T) {
|
|||
r := newResolver()
|
||||
|
||||
tagRW := r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter)
|
||||
tagRW.On("FindByName", existingTagName, true).Return(&models.Tag{
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
tagFilterForName := func(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tagFilterForAlias := func(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Aliases: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tagRW.On("Query", tagFilterForName(existingTagName), findFilter).Return([]*models.Tag{
|
||||
{
|
||||
ID: existingTagID,
|
||||
Name: existingTagName,
|
||||
}, nil).Once()
|
||||
tagRW.On("FindByName", errTagName, true).Return(nil, nil).Once()
|
||||
},
|
||||
}, 1, nil).Once()
|
||||
tagRW.On("Query", tagFilterForName(errTagName), findFilter).Return(nil, 0, nil).Once()
|
||||
tagRW.On("Query", tagFilterForAlias(errTagName), findFilter).Return(nil, 0, nil).Once()
|
||||
|
||||
expectedErr := errors.New("TagCreate error")
|
||||
tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, expectedErr)
|
||||
|
|
@ -55,7 +82,8 @@ func TestTagCreate(t *testing.T) {
|
|||
r = newResolver()
|
||||
tagRW = r.txnManager.(*mocks.TransactionManager).Tag().(*mocks.TagReaderWriter)
|
||||
|
||||
tagRW.On("FindByName", tagName, true).Return(nil, nil).Once()
|
||||
tagRW.On("Query", tagFilterForName(tagName), findFilter).Return(nil, 0, nil).Once()
|
||||
tagRW.On("Query", tagFilterForAlias(tagName), findFilter).Return(nil, 0, nil).Once()
|
||||
tagRW.On("Create", mock.AnythingOfType("models.Tag")).Return(&models.Tag{
|
||||
ID: newTagID,
|
||||
Name: tagName,
|
||||
|
|
|
|||
|
|
@ -459,7 +459,12 @@ func TestParseTagScenes(t *testing.T) {
|
|||
|
||||
for _, s := range tags {
|
||||
if err := withTxn(func(r models.Repository) error {
|
||||
return TagScenes(s, nil, r.Scene())
|
||||
aliases, err := r.Tag().GetAliases(s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return TagScenes(s, nil, aliases, r.Scene())
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
}
|
||||
|
|
@ -481,7 +486,7 @@ func TestParseTagScenes(t *testing.T) {
|
|||
t.Errorf("Error getting scene tags: %s", err.Error())
|
||||
}
|
||||
|
||||
// title is only set on scenes where we expect performer to be set
|
||||
// title is only set on scenes where we expect tag to be set
|
||||
if scene.Title.String == scene.Path && len(tags) == 0 {
|
||||
t.Errorf("Did not set tag '%s' for path '%s'", testName, scene.Path)
|
||||
} else if scene.Title.String != scene.Path && len(tags) > 0 {
|
||||
|
|
@ -604,7 +609,12 @@ func TestParseTagImages(t *testing.T) {
|
|||
|
||||
for _, s := range tags {
|
||||
if err := withTxn(func(r models.Repository) error {
|
||||
return TagImages(s, nil, r.Image())
|
||||
aliases, err := r.Tag().GetAliases(s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return TagImages(s, nil, aliases, r.Image())
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
}
|
||||
|
|
@ -749,7 +759,12 @@ func TestParseTagGalleries(t *testing.T) {
|
|||
|
||||
for _, s := range tags {
|
||||
if err := withTxn(func(r models.Repository) error {
|
||||
return TagGalleries(s, nil, r.Gallery())
|
||||
aliases, err := r.Tag().GetAliases(s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return TagGalleries(s, nil, aliases, r.Gallery())
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,37 +25,62 @@ func getMatchingTags(path string, tagReader models.TagReader) ([]*models.Tag, er
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func getTagTagger(p *models.Tag) tagger {
|
||||
return tagger{
|
||||
func getTagTaggers(p *models.Tag, aliases []string) []tagger {
|
||||
ret := []tagger{{
|
||||
ID: p.ID,
|
||||
Type: "tag",
|
||||
Name: p.Name,
|
||||
}}
|
||||
|
||||
for _, a := range aliases {
|
||||
ret = append(ret, tagger{
|
||||
ID: p.ID,
|
||||
Type: "tag",
|
||||
Name: a,
|
||||
})
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag.
|
||||
func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error {
|
||||
t := getTagTagger(p)
|
||||
func TagScenes(p *models.Tag, paths []string, aliases []string, rw models.SceneReaderWriter) error {
|
||||
t := getTagTaggers(p, aliases)
|
||||
|
||||
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
for _, tt := range t {
|
||||
if err := tt.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
return scene.AddTag(rw, otherID, subjectID)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TagImages searches for images whose path matches the provided tag name and tags the image with the tag.
|
||||
func TagImages(p *models.Tag, paths []string, rw models.ImageReaderWriter) error {
|
||||
t := getTagTagger(p)
|
||||
func TagImages(p *models.Tag, paths []string, aliases []string, rw models.ImageReaderWriter) error {
|
||||
t := getTagTaggers(p, aliases)
|
||||
|
||||
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
for _, tt := range t {
|
||||
if err := tt.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
return image.AddTag(rw, otherID, subjectID)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag.
|
||||
func TagGalleries(p *models.Tag, paths []string, rw models.GalleryReaderWriter) error {
|
||||
t := getTagTagger(p)
|
||||
func TagGalleries(p *models.Tag, paths []string, aliases []string, rw models.GalleryReaderWriter) error {
|
||||
t := getTagTaggers(p, aliases)
|
||||
|
||||
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
for _, tt := range t {
|
||||
if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||
return gallery.AddTag(rw, otherID, subjectID)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,35 +8,67 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTagScenes(t *testing.T) {
|
||||
type test struct {
|
||||
type testTagCase struct {
|
||||
tagName string
|
||||
expectedRegex string
|
||||
}
|
||||
aliasName string
|
||||
aliasRegex string
|
||||
}
|
||||
|
||||
tagNames := []test{
|
||||
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])`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range tagNames {
|
||||
testTagScenes(t, p.tagName, p.expectedRegex)
|
||||
func TestTagScenes(t *testing.T) {
|
||||
for _, p := range testTagCases {
|
||||
testTagScenes(t, p)
|
||||
}
|
||||
}
|
||||
|
||||
func testTagScenes(t *testing.T, tagName, expectedRegex string) {
|
||||
func testTagScenes(t *testing.T, tc testTagCase) {
|
||||
tagName := tc.tagName
|
||||
expectedRegex := tc.expectedRegex
|
||||
aliasName := tc.aliasName
|
||||
aliasRegex := tc.aliasRegex
|
||||
|
||||
mockSceneReader := &mocks.SceneReaderWriter{}
|
||||
|
||||
const tagID = 2
|
||||
|
||||
var aliases []string
|
||||
|
||||
testPathName := tagName
|
||||
if aliasName != "" {
|
||||
aliases = []string{aliasName}
|
||||
testPathName = aliasName
|
||||
}
|
||||
|
||||
matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
|
||||
|
||||
var scenes []*models.Scene
|
||||
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
|
||||
for i, p := range append(matchingPaths, falsePaths...) {
|
||||
scenes = append(scenes, &models.Scene{
|
||||
ID: i + 1,
|
||||
|
|
@ -64,7 +96,23 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
|
|||
PerPage: &perPage,
|
||||
}
|
||||
|
||||
mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once()
|
||||
// if alias provided, then don't find by name
|
||||
onNameQuery := mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter)
|
||||
if aliasName == "" {
|
||||
onNameQuery.Return(scenes, len(scenes), nil).Once()
|
||||
} else {
|
||||
onNameQuery.Return(nil, 0, nil).Once()
|
||||
|
||||
expectedAliasFilter := &models.SceneFilterType{
|
||||
Organized: &organized,
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: aliasRegex,
|
||||
Modifier: models.CriterionModifierMatchesRegex,
|
||||
},
|
||||
}
|
||||
|
||||
mockSceneReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once()
|
||||
}
|
||||
|
||||
for i := range matchingPaths {
|
||||
sceneID := i + 1
|
||||
|
|
@ -72,7 +120,7 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
|
|||
mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once()
|
||||
}
|
||||
|
||||
err := TagScenes(&tag, nil, mockSceneReader)
|
||||
err := TagScenes(&tag, nil, aliases, mockSceneReader)
|
||||
|
||||
assert := assert.New(t)
|
||||
|
||||
|
|
@ -81,34 +129,31 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) {
|
|||
}
|
||||
|
||||
func TestTagImages(t *testing.T) {
|
||||
type test struct {
|
||||
tagName string
|
||||
expectedRegex string
|
||||
}
|
||||
|
||||
tagNames := []test{
|
||||
{
|
||||
"tag name",
|
||||
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
|
||||
},
|
||||
{
|
||||
"tag + name",
|
||||
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range tagNames {
|
||||
testTagImages(t, p.tagName, p.expectedRegex)
|
||||
for _, p := range testTagCases {
|
||||
testTagImages(t, p)
|
||||
}
|
||||
}
|
||||
|
||||
func testTagImages(t *testing.T, tagName, expectedRegex string) {
|
||||
func testTagImages(t *testing.T, tc testTagCase) {
|
||||
tagName := tc.tagName
|
||||
expectedRegex := tc.expectedRegex
|
||||
aliasName := tc.aliasName
|
||||
aliasRegex := tc.aliasRegex
|
||||
|
||||
mockImageReader := &mocks.ImageReaderWriter{}
|
||||
|
||||
const tagID = 2
|
||||
|
||||
var aliases []string
|
||||
|
||||
testPathName := tagName
|
||||
if aliasName != "" {
|
||||
aliases = []string{aliasName}
|
||||
testPathName = aliasName
|
||||
}
|
||||
|
||||
var images []*models.Image
|
||||
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
|
||||
matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
|
||||
for i, p := range append(matchingPaths, falsePaths...) {
|
||||
images = append(images, &models.Image{
|
||||
ID: i + 1,
|
||||
|
|
@ -136,7 +181,23 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
|
|||
PerPage: &perPage,
|
||||
}
|
||||
|
||||
mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once()
|
||||
// if alias provided, then don't find by name
|
||||
onNameQuery := mockImageReader.On("Query", expectedImageFilter, expectedFindFilter)
|
||||
if aliasName == "" {
|
||||
onNameQuery.Return(images, len(images), nil).Once()
|
||||
} else {
|
||||
onNameQuery.Return(nil, 0, nil).Once()
|
||||
|
||||
expectedAliasFilter := &models.ImageFilterType{
|
||||
Organized: &organized,
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: aliasRegex,
|
||||
Modifier: models.CriterionModifierMatchesRegex,
|
||||
},
|
||||
}
|
||||
|
||||
mockImageReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(images, len(images), nil).Once()
|
||||
}
|
||||
|
||||
for i := range matchingPaths {
|
||||
imageID := i + 1
|
||||
|
|
@ -144,7 +205,7 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
|
|||
mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once()
|
||||
}
|
||||
|
||||
err := TagImages(&tag, nil, mockImageReader)
|
||||
err := TagImages(&tag, nil, aliases, mockImageReader)
|
||||
|
||||
assert := assert.New(t)
|
||||
|
||||
|
|
@ -153,34 +214,31 @@ func testTagImages(t *testing.T, tagName, expectedRegex string) {
|
|||
}
|
||||
|
||||
func TestTagGalleries(t *testing.T) {
|
||||
type test struct {
|
||||
tagName string
|
||||
expectedRegex string
|
||||
}
|
||||
|
||||
tagNames := []test{
|
||||
{
|
||||
"tag name",
|
||||
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`,
|
||||
},
|
||||
{
|
||||
"tag + name",
|
||||
`(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range tagNames {
|
||||
testTagGalleries(t, p.tagName, p.expectedRegex)
|
||||
for _, p := range testTagCases {
|
||||
testTagGalleries(t, p)
|
||||
}
|
||||
}
|
||||
|
||||
func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
|
||||
func testTagGalleries(t *testing.T, tc testTagCase) {
|
||||
tagName := tc.tagName
|
||||
expectedRegex := tc.expectedRegex
|
||||
aliasName := tc.aliasName
|
||||
aliasRegex := tc.aliasRegex
|
||||
|
||||
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
||||
|
||||
const tagID = 2
|
||||
|
||||
var aliases []string
|
||||
|
||||
testPathName := tagName
|
||||
if aliasName != "" {
|
||||
aliases = []string{aliasName}
|
||||
testPathName = aliasName
|
||||
}
|
||||
|
||||
var galleries []*models.Gallery
|
||||
matchingPaths, falsePaths := generateTestPaths(tagName, "mp4")
|
||||
matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
|
||||
for i, p := range append(matchingPaths, falsePaths...) {
|
||||
galleries = append(galleries, &models.Gallery{
|
||||
ID: i + 1,
|
||||
|
|
@ -208,7 +266,23 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
|
|||
PerPage: &perPage,
|
||||
}
|
||||
|
||||
mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()
|
||||
// if alias provided, then don't find by name
|
||||
onNameQuery := mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter)
|
||||
if aliasName == "" {
|
||||
onNameQuery.Return(galleries, len(galleries), nil).Once()
|
||||
} else {
|
||||
onNameQuery.Return(nil, 0, nil).Once()
|
||||
|
||||
expectedAliasFilter := &models.GalleryFilterType{
|
||||
Organized: &organized,
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: aliasRegex,
|
||||
Modifier: models.CriterionModifierMatchesRegex,
|
||||
},
|
||||
}
|
||||
|
||||
mockGalleryReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()
|
||||
}
|
||||
|
||||
for i := range matchingPaths {
|
||||
galleryID := i + 1
|
||||
|
|
@ -216,7 +290,7 @@ func testTagGalleries(t *testing.T, tagName, expectedRegex string) {
|
|||
mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once()
|
||||
}
|
||||
|
||||
err := TagGalleries(&tag, nil, mockGalleryReader)
|
||||
err := TagGalleries(&tag, nil, aliases, mockGalleryReader)
|
||||
|
||||
assert := assert.New(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import (
|
|||
var DB *sqlx.DB
|
||||
var WriteMu *sync.Mutex
|
||||
var dbPath string
|
||||
var appSchemaVersion uint = 23
|
||||
var appSchemaVersion uint = 24
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
var (
|
||||
|
|
|
|||
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"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/tag"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -583,7 +584,7 @@ func (p *SceneFilenameParser) queryMovie(qb models.MovieReader, movieName string
|
|||
}
|
||||
|
||||
func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *models.Tag {
|
||||
// massage the performer name
|
||||
// massage the tag name
|
||||
tagName = delimiterRE.ReplaceAllString(tagName, " ")
|
||||
|
||||
// check cache first
|
||||
|
|
@ -592,7 +593,12 @@ func (p *SceneFilenameParser) queryTag(qb models.TagReader, tagName string) *mod
|
|||
}
|
||||
|
||||
// match tag name exactly
|
||||
ret, _ := qb.FindByName(tagName, true)
|
||||
ret, _ := tag.ByName(qb, tagName)
|
||||
|
||||
// try to match on alias
|
||||
if ret == nil {
|
||||
ret, _ = tag.ByAlias(qb, tagName)
|
||||
}
|
||||
|
||||
// add result to cache
|
||||
p.tagCache[tagName] = ret
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
type Tag struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
CreatedAt models.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
|
||||
|
|
|
|||
|
|
@ -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 := autotag.TagScenes(tag, paths, r.Scene()); err != nil {
|
||||
aliases, err := r.Tag().GetAliases(tag.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autotag.TagImages(tag, paths, r.Image()); err != nil {
|
||||
|
||||
if err := autotag.TagScenes(tag, paths, aliases, r.Scene()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autotag.TagGalleries(tag, paths, r.Gallery()); err != nil {
|
||||
if err := autotag.TagImages(tag, paths, aliases, r.Image()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autotag.TagGalleries(tag, paths, aliases, r.Gallery()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,29 @@ func (_m *PerformerReaderWriter) FindBySceneID(sceneID int) ([]*models.Performer
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint
|
||||
func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) {
|
||||
ret := _m.Called(hasStashID, stashboxEndpoint)
|
||||
|
||||
var r0 []*models.Performer
|
||||
if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok {
|
||||
r0 = rf(hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Performer)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(bool, string) error); ok {
|
||||
r1 = rf(hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ids
|
||||
func (_m *PerformerReaderWriter) FindMany(ids []int) ([]*models.Performer, error) {
|
||||
ret := _m.Called(ids)
|
||||
|
|
@ -498,26 +521,3 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error {
|
|||
|
||||
return r0
|
||||
}
|
||||
|
||||
// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint
|
||||
func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) {
|
||||
ret := _m.Called(hasStashID, stashboxEndpoint)
|
||||
|
||||
var r0 []*models.Performer
|
||||
if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok {
|
||||
r0 = rf(hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Performer)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(bool, string) error); ok {
|
||||
r1 = rf(hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -314,6 +314,29 @@ func (_m *TagReaderWriter) FindMany(ids []int) ([]*models.Tag, error) {
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// GetAliases provides a mock function with given fields: tagID
|
||||
func (_m *TagReaderWriter) GetAliases(tagID int) ([]string, error) {
|
||||
ret := _m.Called(tagID)
|
||||
|
||||
var r0 []string
|
||||
if rf, ok := ret.Get(0).(func(int) []string); ok {
|
||||
r0 = rf(tagID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = rf(tagID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetImage provides a mock function with given fields: tagID
|
||||
func (_m *TagReaderWriter) GetImage(tagID int) ([]byte, error) {
|
||||
ret := _m.Called(tagID)
|
||||
|
|
@ -390,8 +413,45 @@ func (_m *TagReaderWriter) QueryForAutoTag(words []string) ([]*models.Tag, error
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: updatedTag
|
||||
func (_m *TagReaderWriter) Update(updatedTag models.Tag) (*models.Tag, error) {
|
||||
// Update provides a mock function with given fields: updateTag
|
||||
func (_m *TagReaderWriter) Update(updateTag models.TagPartial) (*models.Tag, error) {
|
||||
ret := _m.Called(updateTag)
|
||||
|
||||
var r0 *models.Tag
|
||||
if rf, ok := ret.Get(0).(func(models.TagPartial) *models.Tag); ok {
|
||||
r0 = rf(updateTag)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(models.TagPartial) error); ok {
|
||||
r1 = rf(updateTag)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateAliases provides a mock function with given fields: tagID, aliases
|
||||
func (_m *TagReaderWriter) UpdateAliases(tagID int, aliases []string) error {
|
||||
ret := _m.Called(tagID, aliases)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int, []string) error); ok {
|
||||
r0 = rf(tagID, aliases)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateFull provides a mock function with given fields: updatedTag
|
||||
func (_m *TagReaderWriter) UpdateFull(updatedTag models.Tag) (*models.Tag, error) {
|
||||
ret := _m.Called(updatedTag)
|
||||
|
||||
var r0 *models.Tag
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ type Tag struct {
|
|||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type TagPartial struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Name *string `db:"name" json:"name"` // TODO make schema not null
|
||||
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func NewTag(name string) *Tag {
|
||||
currentTime := time.Now()
|
||||
return &Tag{
|
||||
|
|
|
|||
|
|
@ -17,14 +17,17 @@ type TagReader interface {
|
|||
QueryForAutoTag(words []string) ([]*Tag, error)
|
||||
Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error)
|
||||
GetImage(tagID int) ([]byte, error)
|
||||
GetAliases(tagID int) ([]string, error)
|
||||
}
|
||||
|
||||
type TagWriter interface {
|
||||
Create(newTag Tag) (*Tag, error)
|
||||
Update(updatedTag Tag) (*Tag, error)
|
||||
Update(updateTag TagPartial) (*Tag, error)
|
||||
UpdateFull(updatedTag Tag) (*Tag, error)
|
||||
Destroy(id int) error
|
||||
UpdateImage(tagID int, image []byte) error
|
||||
DestroyImage(tagID int) error
|
||||
UpdateAliases(tagID int, aliases []string) error
|
||||
}
|
||||
|
||||
type TagReaderWriter interface {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/tag"
|
||||
)
|
||||
|
||||
// MatchScrapedScenePerformer matches the provided performer with the
|
||||
|
|
@ -66,18 +67,26 @@ func MatchScrapedSceneMovie(qb models.MovieReader, m *models.ScrapedSceneMovie)
|
|||
// MatchScrapedSceneTag matches the provided tag with the tags
|
||||
// in the database and sets the ID field if one is found.
|
||||
func MatchScrapedSceneTag(qb models.TagReader, s *models.ScrapedSceneTag) error {
|
||||
tag, err := qb.FindByName(s.Name, true)
|
||||
t, err := tag.ByName(qb, s.Name)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tag == nil {
|
||||
if t == nil {
|
||||
// try matching by alias
|
||||
t, err = tag.ByAlias(qb, s.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if t == nil {
|
||||
// ignore - cannot match
|
||||
return nil
|
||||
}
|
||||
|
||||
id := strconv.Itoa(tag.ID)
|
||||
id := strconv.Itoa(t.ID)
|
||||
s.ID = &id
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
type stringRepository struct {
|
||||
repository
|
||||
stringColumn string
|
||||
}
|
||||
|
||||
func (r *stringRepository) get(id int) ([]string, error) {
|
||||
query := fmt.Sprintf("SELECT %s from %s WHERE %s = ?", r.stringColumn, r.tableName, r.idColumn)
|
||||
var ret []string
|
||||
err := r.queryFunc(query, []interface{}{id}, func(rows *sqlx.Rows) error {
|
||||
var out string
|
||||
if err := rows.Scan(&out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = append(ret, out)
|
||||
return nil
|
||||
})
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (r *stringRepository) insert(id int, s string) (sql.Result, error) {
|
||||
stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.stringColumn)
|
||||
return r.tx.Exec(stmt, id, s)
|
||||
}
|
||||
|
||||
func (r *stringRepository) replace(id int, newStrings []string) error {
|
||||
if err := r.destroy([]int{id}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range newStrings {
|
||||
if _, err := r.insert(id, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type stashIDRepository struct {
|
||||
repository
|
||||
}
|
||||
|
|
|
|||
|
|
@ -852,6 +852,12 @@ func createTags(tqb models.TagReaderWriter, n int, o int) error {
|
|||
return fmt.Errorf("Error creating tag %v+: %s", tag, err.Error())
|
||||
}
|
||||
|
||||
// add alias
|
||||
alias := getTagStringValue(i, "Alias")
|
||||
if err := tqb.UpdateAliases(created.ID, []string{alias}); err != nil {
|
||||
return fmt.Errorf("error setting tag alias: %s", err.Error())
|
||||
}
|
||||
|
||||
tagIDs = append(tagIDs, created.ID)
|
||||
tagNames = append(tagNames, created.Name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import (
|
|||
|
||||
const tagTable = "tags"
|
||||
const tagIDColumn = "tag_id"
|
||||
const tagAliasesTable = "tag_aliases"
|
||||
const tagAliasColumn = "alias"
|
||||
|
||||
type tagQueryBuilder struct {
|
||||
repository
|
||||
|
|
@ -35,7 +37,16 @@ func (qb *tagQueryBuilder) Create(newObject models.Tag) (*models.Tag, error) {
|
|||
return &ret, nil
|
||||
}
|
||||
|
||||
func (qb *tagQueryBuilder) Update(updatedObject models.Tag) (*models.Tag, error) {
|
||||
func (qb *tagQueryBuilder) Update(updatedObject models.TagPartial) (*models.Tag, error) {
|
||||
const partial = true
|
||||
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return qb.Find(updatedObject.ID)
|
||||
}
|
||||
|
||||
func (qb *tagQueryBuilder) UpdateFull(updatedObject models.Tag) (*models.Tag, error) {
|
||||
const partial = false
|
||||
if err := qb.update(updatedObject.ID, updatedObject, partial); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -197,13 +208,19 @@ func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error
|
|||
// TODO - Query needs to be changed to support queries of this type, and
|
||||
// this method should be removed
|
||||
query := selectAll(tagTable)
|
||||
query += " LEFT JOIN tag_aliases ON tag_aliases.tag_id = tags.id"
|
||||
|
||||
var whereClauses []string
|
||||
var args []interface{}
|
||||
|
||||
for _, w := range words {
|
||||
whereClauses = append(whereClauses, "name like ?")
|
||||
args = append(args, "%"+w+"%")
|
||||
ww := "%" + w + "%"
|
||||
whereClauses = append(whereClauses, "tags.name like ?")
|
||||
args = append(args, ww)
|
||||
|
||||
// include aliases
|
||||
whereClauses = append(whereClauses, "tag_aliases.alias like ?")
|
||||
args = append(args, ww)
|
||||
}
|
||||
|
||||
where := strings.Join(whereClauses, " OR ")
|
||||
|
|
@ -262,6 +279,9 @@ func (qb *tagQueryBuilder) makeFilter(tagFilter *models.TagFilterType) *filterBu
|
|||
// }
|
||||
// }
|
||||
|
||||
query.handleCriterionFunc(stringCriterionHandler(tagFilter.Name, tagTable+".name"))
|
||||
query.handleCriterionFunc(tagAliasCriterionHandler(qb, tagFilter.Aliases))
|
||||
|
||||
query.handleCriterionFunc(tagIsMissingCriterionHandler(qb, tagFilter.IsMissing))
|
||||
query.handleCriterionFunc(tagSceneCountCriterionHandler(qb, tagFilter.SceneCount))
|
||||
query.handleCriterionFunc(tagImageCountCriterionHandler(qb, tagFilter.ImageCount))
|
||||
|
|
@ -297,7 +317,8 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
|
|||
// Disabling querying/sorting on marker count for now.
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"tags.name"}
|
||||
query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id")
|
||||
searchColumns := []string{"tags.name", "tag_aliases.alias"}
|
||||
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||
query.addWhere(clause)
|
||||
query.addArg(thisArgs...)
|
||||
|
|
@ -328,6 +349,18 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
|
|||
return tags, countResult, nil
|
||||
}
|
||||
|
||||
func tagAliasCriterionHandler(qb *tagQueryBuilder, alias *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
joinTable: tagAliasesTable,
|
||||
stringColumn: tagAliasColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
qb.aliasRepository().join(f, "", "tags.id")
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(alias)
|
||||
}
|
||||
|
||||
func tagIsMissingCriterionHandler(qb *tagQueryBuilder, isMissing *string) criterionHandlerFunc {
|
||||
return func(f *filterBuilder) {
|
||||
if isMissing != nil && *isMissing != "" {
|
||||
|
|
@ -484,3 +517,22 @@ func (qb *tagQueryBuilder) UpdateImage(tagID int, image []byte) error {
|
|||
func (qb *tagQueryBuilder) DestroyImage(tagID int) error {
|
||||
return qb.imageRepository().destroy([]int{tagID})
|
||||
}
|
||||
|
||||
func (qb *tagQueryBuilder) aliasRepository() *stringRepository {
|
||||
return &stringRepository{
|
||||
repository: repository{
|
||||
tx: qb.tx,
|
||||
tableName: tagAliasesTable,
|
||||
idColumn: tagIDColumn,
|
||||
},
|
||||
stringColumn: tagAliasColumn,
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *tagQueryBuilder) GetAliases(tagID int) ([]string, error) {
|
||||
return qb.aliasRepository().get(tagID)
|
||||
}
|
||||
|
||||
func (qb *tagQueryBuilder) UpdateAliases(tagID int, aliases []string) error {
|
||||
return qb.aliasRepository().replace(tagID, aliases)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,8 +83,20 @@ func TestTagQueryForAutoTag(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.Len(t, tags, 2)
|
||||
assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name))
|
||||
assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name))
|
||||
lcName := tagNames[tagIdxWithScene]
|
||||
assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[0].Name))
|
||||
assert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[1].Name))
|
||||
|
||||
// find by alias
|
||||
name = getTagStringValue(tagIdxWithScene, "Alias")
|
||||
tags, err = tqb.QueryForAutoTag([]string{name})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding tags: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, tags, 1)
|
||||
assert.Equal(t, tagIDs[tagIdxWithScene], tags[0].ID)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
|
@ -137,6 +149,100 @@ func TestTagFindByNames(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestTagQueryName(t *testing.T) {
|
||||
const tagIdx = 1
|
||||
tagName := getSceneStringValue(tagIdx, "Name")
|
||||
|
||||
nameCriterion := &models.StringCriterionInput{
|
||||
Value: tagName,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
||||
tagFilter := &models.TagFilterType{
|
||||
Name: nameCriterion,
|
||||
}
|
||||
|
||||
verifyFn := func(tag *models.Tag, r models.Repository) {
|
||||
verifyString(t, tag.Name, *nameCriterion)
|
||||
}
|
||||
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
nameCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
nameCriterion.Modifier = models.CriterionModifierMatchesRegex
|
||||
nameCriterion.Value = "tag_.*1_Name"
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
}
|
||||
|
||||
func TestTagQueryAlias(t *testing.T) {
|
||||
const tagIdx = 1
|
||||
tagName := getSceneStringValue(tagIdx, "Alias")
|
||||
|
||||
aliasCriterion := &models.StringCriterionInput{
|
||||
Value: tagName,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
||||
tagFilter := &models.TagFilterType{
|
||||
Aliases: aliasCriterion,
|
||||
}
|
||||
|
||||
verifyFn := func(tag *models.Tag, r models.Repository) {
|
||||
aliases, err := r.Tag().GetAliases(tag.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Error querying tags: %s", err.Error())
|
||||
}
|
||||
|
||||
var alias string
|
||||
if len(aliases) > 0 {
|
||||
alias = aliases[0]
|
||||
}
|
||||
|
||||
verifyString(t, alias, *aliasCriterion)
|
||||
}
|
||||
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
aliasCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
aliasCriterion.Modifier = models.CriterionModifierMatchesRegex
|
||||
aliasCriterion.Value = "tag_.*1_Alias"
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
|
||||
aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex
|
||||
verifyTagQuery(t, tagFilter, nil, verifyFn)
|
||||
}
|
||||
|
||||
func verifyTagQuery(t *testing.T, tagFilter *models.TagFilterType, findFilter *models.FindFilterType, verifyFn func(t *models.Tag, r models.Repository)) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Tag()
|
||||
|
||||
tags := queryTags(t, sqb, tagFilter, findFilter)
|
||||
|
||||
for _, tag := range tags {
|
||||
verifyFn(tag, r)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func queryTags(t *testing.T, qb models.TagReader, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) []*models.Tag {
|
||||
t.Helper()
|
||||
tags, _, err := qb.Query(tagFilter, findFilter)
|
||||
if err != nil {
|
||||
t.Errorf("Error querying tags: %s", err.Error())
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
func TestTagQueryIsMissingImage(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
qb := r.Tag()
|
||||
|
|
@ -461,6 +567,39 @@ func TestTagDestroyTagImage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTagUpdateAlias(t *testing.T) {
|
||||
if err := withTxn(func(r models.Repository) error {
|
||||
qb := r.Tag()
|
||||
|
||||
// create tag to test against
|
||||
const name = "TestTagUpdateAlias"
|
||||
tag := models.Tag{
|
||||
Name: name,
|
||||
}
|
||||
created, err := qb.Create(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating tag: %s", err.Error())
|
||||
}
|
||||
|
||||
aliases := []string{"alias1", "alias2"}
|
||||
err = qb.UpdateAliases(created.ID, aliases)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error updating tag aliases: %s", err.Error())
|
||||
}
|
||||
|
||||
// ensure aliases set
|
||||
storedAliases, err := qb.GetAliases(created.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error getting aliases: %s", err.Error())
|
||||
}
|
||||
assert.Equal(t, aliases, storedAliases)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Create
|
||||
// TODO Update
|
||||
// TODO Destroy
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ func ToJSON(reader models.TagReader, tag *models.Tag) (*jsonschema.Tag, error) {
|
|||
UpdatedAt: models.JSONTime{Time: tag.UpdatedAt.Timestamp},
|
||||
}
|
||||
|
||||
aliases, err := reader.GetAliases(tag.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting tag aliases: %s", err.Error())
|
||||
}
|
||||
|
||||
newTagJSON.Aliases = aliases
|
||||
|
||||
image, err := reader.GetImage(tag.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting tag image: %s", err.Error())
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const (
|
|||
tagID = 1
|
||||
noImageID = 2
|
||||
errImageID = 3
|
||||
errAliasID = 4
|
||||
)
|
||||
|
||||
const tagName = "testTag"
|
||||
|
|
@ -36,9 +37,10 @@ func createTag(id int) models.Tag {
|
|||
}
|
||||
}
|
||||
|
||||
func createJSONTag(image string) *jsonschema.Tag {
|
||||
func createJSONTag(aliases []string, image string) *jsonschema.Tag {
|
||||
return &jsonschema.Tag{
|
||||
Name: tagName,
|
||||
Aliases: aliases,
|
||||
CreatedAt: models.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
|
|
@ -59,21 +61,26 @@ var scenarios []testScenario
|
|||
|
||||
func initTestTable() {
|
||||
scenarios = []testScenario{
|
||||
testScenario{
|
||||
{
|
||||
createTag(tagID),
|
||||
createJSONTag("PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="),
|
||||
createJSONTag([]string{"alias"}, "PHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMjAwIgogICBoZWlnaHQ9IjIwMCIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC40IHI5OTM5IgogICBzb2RpcG9kaTpkb2NuYW1lPSJ0YWcuc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzNCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9ImJhc2UiCiAgICAgcGFnZWNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEuMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnpvb209IjEiCiAgICAgaW5rc2NhcGU6Y3g9IjE4MS43Nzc3MSIKICAgICBpbmtzY2FwZTpjeT0iMjc5LjcyMzc2IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXVuaXRzPSJweCIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDE3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTU3Ljg0MzU4LC01MjQuNjk1MjIpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDI5ODciCiAgICAgICBkPSJtIDIyOS45NDMxNCw2NjkuMjY1NDkgLTM2LjA4NDY2LC0zNi4wODQ2NiBjIC00LjY4NjUzLC00LjY4NjUzIC00LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSBsIDM2LjA4NDY2LC0zNi4wODQ2NyBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgOC40ODU2LC0zLjUxNDggbCA3NC45MTQ0MywwIGMgNi42Mjc2MSwwIDEyLjAwMDQxLDUuMzcyOCAxMi4wMDA0MSwxMi4wMDA0MSBsIDAsNzIuMTY5MzMgYyAwLDYuNjI3NjEgLTUuMzcyOCwxMi4wMDA0MSAtMTIuMDAwNDEsMTIuMDAwNDEgbCAtNzQuOTE0NDMsMCBhIDEyLjAwMDQ1MywxMi4wMDA0NTMgMCAwIDEgLTguNDg1NiwtMy41MTQ4MSB6IG0gLTEzLjQ1NjM5LC01My4wNTU4NyBjIC00LjY4NjUzLDQuNjg2NTMgLTQuNjg2NTMsMTIuMjg0NjggMCwxNi45NzEyMSA0LjY4NjUyLDQuNjg2NTIgMTIuMjg0NjcsNC42ODY1MiAxNi45NzEyLDAgNC42ODY1MywtNC42ODY1MyA0LjY4NjUzLC0xMi4yODQ2OCAwLC0xNi45NzEyMSAtNC42ODY1MywtNC42ODY1MiAtMTIuMjg0NjgsLTQuNjg2NTIgLTE2Ljk3MTIsMCB6IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4="),
|
||||
false,
|
||||
},
|
||||
testScenario{
|
||||
{
|
||||
createTag(noImageID),
|
||||
createJSONTag(""),
|
||||
createJSONTag(nil, ""),
|
||||
false,
|
||||
},
|
||||
testScenario{
|
||||
{
|
||||
createTag(errImageID),
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
createTag(errAliasID),
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +90,12 @@ func TestToJSON(t *testing.T) {
|
|||
mockTagReader := &mocks.TagReaderWriter{}
|
||||
|
||||
imageErr := errors.New("error getting image")
|
||||
aliasErr := errors.New("error getting aliases")
|
||||
|
||||
mockTagReader.On("GetAliases", tagID).Return([]string{"alias"}, nil).Once()
|
||||
mockTagReader.On("GetAliases", noImageID).Return(nil, nil).Once()
|
||||
mockTagReader.On("GetAliases", errImageID).Return(nil, nil).Once()
|
||||
mockTagReader.On("GetAliases", errAliasID).Return(nil, aliasErr).Once()
|
||||
|
||||
mockTagReader.On("GetImage", tagID).Return(models.DefaultTagImage, nil).Once()
|
||||
mockTagReader.On("GetImage", noImageID).Return(nil, nil).Once()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ func (i *Importer) PostImport(id int) error {
|
|||
}
|
||||
}
|
||||
|
||||
if err := i.ReaderWriter.UpdateAliases(id, i.Input.Aliases); err != nil {
|
||||
return fmt.Errorf("error setting tag aliases: %s", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ func (i *Importer) Create() (*int, error) {
|
|||
func (i *Importer) Update(id int) error {
|
||||
tag := i.tag
|
||||
tag.ID = id
|
||||
_, err := i.ReaderWriter.Update(tag)
|
||||
_, err := i.ReaderWriter.UpdateFull(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating existing tag: %s", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,12 +56,20 @@ func TestImporterPostImport(t *testing.T) {
|
|||
|
||||
i := Importer{
|
||||
ReaderWriter: readerWriter,
|
||||
Input: jsonschema.Tag{
|
||||
Aliases: []string{"alias"},
|
||||
},
|
||||
imageData: imageBytes,
|
||||
}
|
||||
|
||||
updateTagImageErr := errors.New("UpdateImage error")
|
||||
updateTagAliasErr := errors.New("UpdateAlias error")
|
||||
|
||||
readerWriter.On("UpdateAliases", tagID, i.Input.Aliases).Return(nil).Once()
|
||||
readerWriter.On("UpdateAliases", errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once()
|
||||
|
||||
readerWriter.On("UpdateImage", tagID, imageBytes).Return(nil).Once()
|
||||
readerWriter.On("UpdateImage", errAliasID, imageBytes).Return(nil).Once()
|
||||
readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateTagImageErr).Once()
|
||||
|
||||
err := i.PostImport(tagID)
|
||||
|
|
@ -70,6 +78,9 @@ func TestImporterPostImport(t *testing.T) {
|
|||
err = i.PostImport(errImageID)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
err = i.PostImport(errAliasID)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
readerWriter.AssertExpectations(t)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +172,7 @@ func TestUpdate(t *testing.T) {
|
|||
|
||||
// id needs to be set for the mock input
|
||||
tag.ID = tagID
|
||||
readerWriter.On("Update", tag).Return(nil, nil).Once()
|
||||
readerWriter.On("UpdateFull", tag).Return(nil, nil).Once()
|
||||
|
||||
err := i.Update(tagID)
|
||||
assert.Nil(t, err)
|
||||
|
|
@ -170,7 +181,7 @@ func TestUpdate(t *testing.T) {
|
|||
|
||||
// need to set id separately
|
||||
tagErr.ID = errImageID
|
||||
readerWriter.On("Update", tagErr).Return(nil, errUpdate).Once()
|
||||
readerWriter.On("UpdateFull", tagErr).Return(nil, errUpdate).Once()
|
||||
|
||||
err = i.Update(errImageID)
|
||||
assert.NotNil(t, err)
|
||||
|
|
|
|||
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
|
||||
* Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412))
|
||||
* Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393))
|
||||
* Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379))
|
||||
* Added Handy/Funscript support. ([#1377](https://github.com/stashapp/stash/pull/1377))
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
|
|||
|
||||
const [createStudio] = useStudioCreate({ name: "" });
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
const [createTag] = useTagCreate({ name: "" });
|
||||
const [createTag] = useTagCreate();
|
||||
|
||||
const Toast = useToast();
|
||||
|
||||
|
|
@ -337,7 +337,9 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
|
|||
try {
|
||||
tagInput = Object.assign(tagInput, toCreate);
|
||||
const result = await createTag({
|
||||
variables: tagInput,
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 [createTag] = useTagCreate({ name: "" });
|
||||
const [createTag] = useTagCreate();
|
||||
|
||||
const genderOptions = [""].concat(getGenderStrings());
|
||||
|
||||
|
|
@ -225,7 +225,9 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
try {
|
||||
tagInput = Object.assign(tagInput, toCreate);
|
||||
const result = await createTag({
|
||||
variables: tagInput,
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.data?.tagCreate) {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
new ScrapeResult<string>(props.performer.details, props.scraped.details)
|
||||
);
|
||||
|
||||
const [createTag] = useTagCreate({ name: "" });
|
||||
const [createTag] = useTagCreate();
|
||||
const Toast = useToast();
|
||||
|
||||
interface IHasStoredID {
|
||||
|
|
@ -318,7 +318,9 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
try {
|
||||
tagInput = Object.assign(tagInput, toCreate);
|
||||
const result = await createTag({
|
||||
variables: tagInput,
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
// add the new tag to the new tags value
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
|
|||
const [createStudio] = useStudioCreate({ name: "" });
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
const [createMovie] = useMovieCreate();
|
||||
const [createTag] = useTagCreate({ name: "" });
|
||||
const [createTag] = useTagCreate();
|
||||
|
||||
const Toast = useToast();
|
||||
|
||||
|
|
@ -449,7 +449,9 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
|
|||
try {
|
||||
tagInput = Object.assign(tagInput, toCreate);
|
||||
const result = await createTag({
|
||||
variables: tagInput,
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
// add the new tag to the new tags value
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import React, { useState } from "react";
|
||||
import Select, { ValueType, Styles } from "react-select";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Select, {
|
||||
ValueType,
|
||||
Styles,
|
||||
OptionProps,
|
||||
components as reactSelectComponents,
|
||||
GroupedOptionsType,
|
||||
OptionsType,
|
||||
} from "react-select";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
|
|
@ -16,6 +23,7 @@ import {
|
|||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { SelectComponents } from "react-select/src/components";
|
||||
|
||||
export type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
|
|
@ -59,6 +67,13 @@ interface ISelectProps<T extends boolean> {
|
|||
isMulti: T;
|
||||
isClearable?: boolean;
|
||||
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;
|
||||
showDropdown?: boolean;
|
||||
groupHeader?: string;
|
||||
|
|
@ -109,6 +124,9 @@ const SelectComponent = <T extends boolean>({
|
|||
creatable = false,
|
||||
isMulti,
|
||||
onInputChange,
|
||||
filterOption,
|
||||
isValidNewOption,
|
||||
components,
|
||||
placeholder,
|
||||
showDropdown = true,
|
||||
groupHeader,
|
||||
|
|
@ -158,12 +176,15 @@ const SelectComponent = <T extends boolean>({
|
|||
noOptionsMessage: () => noOptionsMessage,
|
||||
placeholder: isDisabled ? "" : placeholder,
|
||||
onInputChange,
|
||||
filterOption,
|
||||
isValidNewOption,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
styles,
|
||||
closeMenuOnSelect,
|
||||
menuPortalTarget,
|
||||
components: {
|
||||
...components,
|
||||
IndicatorSeparator: () => null,
|
||||
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),
|
||||
...(isDisabled && { MultiValueRemove: () => null }),
|
||||
|
|
@ -454,22 +475,108 @@ export const MovieSelect: 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 [createTag] = useTagCreate({ name: "" });
|
||||
const [createTag] = useTagCreate();
|
||||
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 result = await createTag({
|
||||
variables: { name },
|
||||
variables: {
|
||||
input: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
});
|
||||
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 (
|
||||
<FilterSelectComponent
|
||||
{...props}
|
||||
filterOption={filterOption}
|
||||
isValidNewOption={isValidNewOption}
|
||||
components={{ Option: TagOption }}
|
||||
isMulti={props.isMulti ?? false}
|
||||
items={tags}
|
||||
creatable={props.creatable ?? true}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface IStringListInputProps {
|
|||
setValue: (value: string[]) => void;
|
||||
defaultNewValue?: string;
|
||||
className?: string;
|
||||
errors?: string;
|
||||
}
|
||||
|
||||
export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
||||
|
|
@ -32,6 +33,8 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={props.errors && "is-invalid"}>
|
||||
{props.value && props.value.length > 0 && (
|
||||
<Form.Group>
|
||||
{props.value &&
|
||||
props.value.map((v, i) => (
|
||||
|
|
@ -52,9 +55,12 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
|||
</InputGroup>
|
||||
))}
|
||||
</Form.Group>
|
||||
<Button className="minimal" onClick={() => addValue()}>
|
||||
)}
|
||||
<Button className="minimal" size="sm" onClick={() => addValue()}>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="invalid-feedback">{props.errors}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -269,8 +269,10 @@ export const useCreateTag = () => {
|
|||
const handleCreate = (tag: string) =>
|
||||
createTag({
|
||||
variables: {
|
||||
input: {
|
||||
name: tag,
|
||||
},
|
||||
},
|
||||
update: (store, result) => {
|
||||
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 { useParams, useHistory } from "react-router-dom";
|
||||
import cx from "classnames";
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
useTagDestroy,
|
||||
mutateMetadataAutoTag,
|
||||
} from "src/core/StashService";
|
||||
import { ImageUtils, TableUtils } from "src/utils";
|
||||
import { ImageUtils } from "src/utils";
|
||||
import {
|
||||
DetailsEditNavbar,
|
||||
Modal,
|
||||
|
|
@ -24,6 +24,8 @@ import { TagMarkersPanel } from "./TagMarkersPanel";
|
|||
import { TagImagesPanel } from "./TagImagesPanel";
|
||||
import { TagPerformersPanel } from "./TagPerformersPanel";
|
||||
import { TagGalleriesPanel } from "./TagGalleriesPanel";
|
||||
import { TagDetailsPanel } from "./TagDetailsPanel";
|
||||
import { TagEditPanel } from "./TagEditPanel";
|
||||
|
||||
interface ITabParams {
|
||||
id?: string;
|
||||
|
|
@ -42,16 +44,14 @@ export const Tag: React.FC = () => {
|
|||
|
||||
// Editing tag state
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [name, setName] = useState<string>();
|
||||
|
||||
// Tag state
|
||||
const [tag, setTag] = useState<GQL.TagDataFragment | undefined>();
|
||||
const [imagePreview, setImagePreview] = useState<string>();
|
||||
|
||||
const { data, error, loading } = useFindTag(id);
|
||||
const tag = data?.findTag;
|
||||
|
||||
const [updateTag] = useTagUpdate();
|
||||
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput);
|
||||
const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput);
|
||||
const [createTag] = useTagCreate();
|
||||
const [deleteTag] = useTagDestroy({ id });
|
||||
|
||||
const activeTabKey =
|
||||
tab === "markers" ||
|
||||
|
|
@ -69,10 +69,6 @@ export const Tag: React.FC = () => {
|
|||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
Mousetrap.bind("s s", () => onSave());
|
||||
}
|
||||
|
||||
Mousetrap.bind("e", () => setIsEditing(true));
|
||||
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(() => {
|
||||
if (data && data.findTag) {
|
||||
setImage(undefined);
|
||||
updateTagEditState(data.findTag);
|
||||
setImagePreview(data.findTag.image_path ?? undefined);
|
||||
setTag(data.findTag);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
function onImageLoad(imageData: string) {
|
||||
setImagePreview(imageData);
|
||||
setImage(imageData);
|
||||
}
|
||||
|
||||
|
|
@ -118,37 +99,44 @@ export const Tag: React.FC = () => {
|
|||
if (error) return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
function getTagInput() {
|
||||
function getTagInput(
|
||||
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
|
||||
) {
|
||||
const ret: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = {
|
||||
...input,
|
||||
image,
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
image,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
image,
|
||||
};
|
||||
(ret as GQL.TagUpdateInput).id = id;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onSave(
|
||||
input: Partial<GQL.TagCreateInput | GQL.TagUpdateInput>
|
||||
) {
|
||||
try {
|
||||
if (!isNew) {
|
||||
const result = await updateTag({
|
||||
variables: {
|
||||
input: getTagInput() as GQL.TagUpdateInput,
|
||||
input: getTagInput(input) as GQL.TagUpdateInput,
|
||||
},
|
||||
});
|
||||
if (result.data?.tagUpdate) {
|
||||
updateTagData(result.data.tagUpdate);
|
||||
setIsEditing(false);
|
||||
return result.data.tagUpdate.id;
|
||||
}
|
||||
} else {
|
||||
const result = await createTag();
|
||||
const result = await createTag({
|
||||
variables: {
|
||||
input: getTagInput(input) as GQL.TagCreateInput,
|
||||
},
|
||||
});
|
||||
if (result.data?.tagCreate?.id) {
|
||||
history.push(`/tags/${result.data.tagCreate.id}`);
|
||||
setIsEditing(false);
|
||||
return result.data.tagCreate.id;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -177,10 +165,6 @@ export const Tag: React.FC = () => {
|
|||
history.push(`/tags`);
|
||||
}
|
||||
|
||||
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -189,23 +173,29 @@ export const Tag: React.FC = () => {
|
|||
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function onToggleEdit() {
|
||||
setIsEditing(!isEditing);
|
||||
if (tag) {
|
||||
updateTagData(tag);
|
||||
setImage(undefined);
|
||||
}
|
||||
|
||||
function renderImage() {
|
||||
let tagImage = tag?.image_path;
|
||||
if (isEditing) {
|
||||
if (image === null) {
|
||||
tagImage = `${tagImage}&default=true`;
|
||||
} else if (image) {
|
||||
tagImage = image;
|
||||
}
|
||||
}
|
||||
|
||||
function onClearImage() {
|
||||
setImage(null);
|
||||
setImagePreview(
|
||||
tag?.image_path ? `${tag.image_path}&default=true` : undefined
|
||||
);
|
||||
if (tagImage) {
|
||||
return <img className="logo" alt={tag?.name ?? ""} src={tagImage} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -216,38 +206,39 @@ export const Tag: React.FC = () => {
|
|||
"col-8": isNew,
|
||||
})}
|
||||
>
|
||||
{isNew && <h2>Add Tag</h2>}
|
||||
<div className="text-center">
|
||||
<div className="text-center logo-container">
|
||||
{imageEncoding ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<img className="logo" alt={name} src={imagePreview} />
|
||||
renderImage()
|
||||
)}
|
||||
{!isNew && tag && <h2>{tag.name}</h2>}
|
||||
</div>
|
||||
<Table>
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Name",
|
||||
value: name ?? "",
|
||||
isEditing: !!isEditing,
|
||||
onChange: setName,
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
{!isEditing && !isNew && tag ? (
|
||||
<>
|
||||
<TagDetailsPanel tag={tag} />
|
||||
{/* HACK - this is also rendered in the TagEditPanel */}
|
||||
<DetailsEditNavbar
|
||||
objectName={name ?? "tag"}
|
||||
objectName={tag.name ?? "tag"}
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={onToggleEdit}
|
||||
onSave={onSave}
|
||||
onImageChange={onImageChangeHandler}
|
||||
onClearImage={() => {
|
||||
onClearImage();
|
||||
}}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onClearImage={() => {}}
|
||||
onAutoTag={onAutoTag}
|
||||
onDelete={onDelete}
|
||||
acceptSVG
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TagEditPanel
|
||||
tag={tag ?? undefined}
|
||||
onSubmit={onSave}
|
||||
onCancel={onToggleEdit}
|
||||
onDelete={onDelete}
|
||||
setImage={setImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isNew && tag && (
|
||||
<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 {
|
||||
.logo {
|
||||
margin-bottom: 4rem;
|
||||
max-height: 50vh;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -651,9 +651,8 @@ export const tagMutationImpactedQueries = [
|
|||
GQL.FindTagsDocument,
|
||||
];
|
||||
|
||||
export const useTagCreate = (input: GQL.TagCreateInput) =>
|
||||
export const useTagCreate = () =>
|
||||
GQL.useTagCreateMutation({
|
||||
variables: input,
|
||||
refetchQueries: getQueryNames([
|
||||
GQL.AllTagsDocument,
|
||||
GQL.AllTagsForFilterDocument,
|
||||
|
|
|
|||
Loading…
Reference in a new issue