mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
Feature: Tag StashID support (#6255)
This commit is contained in:
parent
a08d2e258a
commit
c99825a453
30 changed files with 387 additions and 34 deletions
|
|
@ -71,6 +71,8 @@ type ScrapedTag {
|
|||
"Set if tag matched"
|
||||
stored_id: ID
|
||||
name: String!
|
||||
"Remote site ID, if applicable"
|
||||
remote_site_id: String
|
||||
}
|
||||
|
||||
type ScrapedScene {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type Tag {
|
|||
created_at: Time!
|
||||
updated_at: Time!
|
||||
favorite: Boolean!
|
||||
stash_ids: [StashID!]!
|
||||
image_path: String # Resolver
|
||||
scene_count(depth: Int): Int! # Resolver
|
||||
scene_marker_count(depth: Int): Int! # Resolver
|
||||
|
|
@ -35,6 +36,7 @@ input TagCreateInput {
|
|||
favorite: Boolean
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
|
@ -51,6 +53,7 @@ input TagUpdateInput {
|
|||
favorite: Boolean
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []strin
|
|||
return obj.Aliases.List(), nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
return obj.LoadStashIDs(ctx, r.repository.Tag)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth)
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Load StashIDs for tags
|
||||
tqb := r.repository.Tag
|
||||
for _, t := range draft.Tags {
|
||||
if err := t.LoadStashIDs(ctx, tqb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
draft.Cover = cover
|
||||
|
||||
return draft, nil
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
|||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
|
||||
var stashIDInputs models.StashIDInputs
|
||||
for _, sid := range input.StashIds {
|
||||
if sid != nil {
|
||||
stashIDInputs = append(stashIDInputs, *sid)
|
||||
}
|
||||
}
|
||||
newTag.StashIDs = models.NewRelatedStashIDs(stashIDInputs.ToStashIDs())
|
||||
|
||||
var err error
|
||||
|
||||
newTag.ParentIDs, err = translator.relatedIds(input.ParentIds)
|
||||
|
|
@ -110,6 +118,14 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
|
||||
updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases")
|
||||
|
||||
var updateStashIDInputs models.StashIDInputs
|
||||
for _, sid := range input.StashIds {
|
||||
if sid != nil {
|
||||
updateStashIDInputs = append(updateStashIDInputs, *sid)
|
||||
}
|
||||
}
|
||||
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
|
||||
|
||||
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
|
|||
tagIDs = originalTagIDs
|
||||
}
|
||||
|
||||
endpoint := g.result.source.RemoteSite
|
||||
|
||||
for _, t := range scraped {
|
||||
if t.StoredID != nil {
|
||||
// existing tag, just add it
|
||||
|
|
@ -163,10 +165,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
|
|||
|
||||
tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID))
|
||||
} else if createMissing {
|
||||
newTag := models.NewTag()
|
||||
newTag.Name = t.Name
|
||||
newTag := t.ToTag(endpoint, nil)
|
||||
|
||||
err := g.tagCreator.Create(ctx, &newTag)
|
||||
err := g.tagCreator.Create(ctx, newTag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tag: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.Sc
|
|||
}
|
||||
|
||||
for _, t := range s.Tags {
|
||||
err := ScrapedTag(ctx, r.TagFinder, t)
|
||||
err := ScrapedTag(ctx, r.TagFinder, t, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -190,11 +190,29 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
|
|||
|
||||
// ScrapedTag matches the provided tag with the tags
|
||||
// in the database and sets the ID field if one is found.
|
||||
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag) error {
|
||||
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if s.StoredID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if a tag with the StashID already exists
|
||||
if stashBoxEndpoint != "" && s.RemoteSiteID != nil {
|
||||
if finder, ok := qb.(models.TagFinder); ok {
|
||||
tags, err := finder.FindByStashID(ctx, models.StashID{
|
||||
StashID: *s.RemoteSiteID,
|
||||
Endpoint: stashBoxEndpoint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
id := strconv.Itoa(tags[0].ID)
|
||||
s.StoredID = &id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t, err := tag.ByName(ctx, qb, s.Name)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ type Tag struct {
|
|||
Image string `json:"image,omitempty"`
|
||||
Parents []string `json:"parents,omitempty"`
|
||||
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
|
||||
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByStashID provides a mock function with given fields: ctx, stashID
|
||||
func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, stashID)
|
||||
|
||||
var r0 []*models.Tag
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Tag); ok {
|
||||
r0 = rf(ctx, stashID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok {
|
||||
r1 = rf(ctx, stashID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByStudioID provides a mock function with given fields: ctx, studioID
|
||||
func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, studioID)
|
||||
|
|
@ -565,6 +588,29 @@ func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]i
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// GetStashIDs provides a mock function with given fields: ctx, relatedID
|
||||
func (_m *TagReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) {
|
||||
ret := _m.Called(ctx, relatedID)
|
||||
|
||||
var r0 []models.StashID
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok {
|
||||
r0 = rf(ctx, relatedID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]models.StashID)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, relatedID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// HasImage provides a mock function with given fields: ctx, tagID
|
||||
func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {
|
||||
ret := _m.Called(ctx, tagID)
|
||||
|
|
|
|||
|
|
@ -449,10 +449,29 @@ type ScrapedTag struct {
|
|||
// Set if tag matched
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
}
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
||||
func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
||||
currentTime := time.Now()
|
||||
ret := NewTag()
|
||||
ret.Name = t.Name
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
StashID: *t.RemoteSiteID,
|
||||
UpdatedAt: currentTime,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &ret
|
||||
}
|
||||
|
||||
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type Tag struct {
|
|||
Aliases RelatedStrings `json:"aliases"`
|
||||
ParentIDs RelatedIDs `json:"parent_ids"`
|
||||
ChildIDs RelatedIDs `json:"tag_ids"`
|
||||
StashIDs RelatedStashIDs `json:"stash_ids"`
|
||||
}
|
||||
|
||||
func NewTag() Tag {
|
||||
|
|
@ -46,6 +47,12 @@ func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Tag) LoadStashIDs(ctx context.Context, l StashIDLoader) error {
|
||||
return s.StashIDs.load(func() ([]StashID, error) {
|
||||
return l.GetStashIDs(ctx, s.ID)
|
||||
})
|
||||
}
|
||||
|
||||
type TagPartial struct {
|
||||
Name OptionalString
|
||||
SortName OptionalString
|
||||
|
|
@ -58,6 +65,7 @@ type TagPartial struct {
|
|||
Aliases *UpdateStrings
|
||||
ParentIDs *UpdateIDs
|
||||
ChildIDs *UpdateIDs
|
||||
StashIDs *UpdateStashIDs
|
||||
}
|
||||
|
||||
func NewTagPartial() TagPartial {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type TagFinder interface {
|
|||
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
|
||||
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
|
||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
|
||||
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
|
||||
}
|
||||
|
||||
// TagQueryer provides methods to query tags.
|
||||
|
|
@ -87,6 +88,7 @@ type TagReader interface {
|
|||
|
||||
AliasLoader
|
||||
TagRelationLoader
|
||||
StashIDLoader
|
||||
|
||||
All(ctx context.Context) ([]*Tag, error)
|
||||
GetImage(ctx context.Context, tagID int) ([]byte, error)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*
|
|||
ret = make([]*models.ScrapedTag, 0, len(scrapedTags))
|
||||
|
||||
for _, t := range scrapedTags {
|
||||
err := match.ScrapedTag(ctx, tqb, t)
|
||||
// Pass empty string for endpoint since this is used by general scrapers, not just stash-box
|
||||
err := match.ScrapedTag(ctx, tqb, t, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ func (db *Anonymiser) deleteStashIDs() error {
|
|||
func() error { return db.truncateTable("scene_stash_ids") },
|
||||
func() error { return db.truncateTable("studio_stash_ids") },
|
||||
func() error { return db.truncateTable("performer_stash_ids") },
|
||||
func() error { return db.truncateTable("tag_stash_ids") },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const (
|
|||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 73
|
||||
var appSchemaVersion uint = 74
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
|||
7
pkg/sqlite/migrations/74_tag_stash_ids.up.sql
Normal file
7
pkg/sqlite/migrations/74_tag_stash_ids.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE `tag_stash_ids` (
|
||||
`tag_id` integer,
|
||||
`endpoint` varchar(255),
|
||||
`stash_id` varchar(36),
|
||||
`updated_at` datetime not null default '1970-01-01T00:00:00Z',
|
||||
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE
|
||||
);
|
||||
|
|
@ -29,6 +29,7 @@ func testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderW
|
|||
stashID := models.StashID{
|
||||
StashID: stashIDStr,
|
||||
Endpoint: endpoint,
|
||||
UpdatedAt: epochTime,
|
||||
}
|
||||
|
||||
// update stash ids and ensure was updated
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ var (
|
|||
|
||||
tagsAliasesJoinTable = goqu.T(tagAliasesTable)
|
||||
tagRelationsJoinTable = goqu.T(tagRelationsTable)
|
||||
tagsStashIDsJoinTable = goqu.T("tag_stash_ids")
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -375,6 +376,13 @@ var (
|
|||
}
|
||||
|
||||
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
|
||||
|
||||
tagsStashIDsTableMgr = &stashIDTable{
|
||||
table: table{
|
||||
table: tagsStashIDsJoinTable,
|
||||
idColumn: tagsStashIDsJoinTable.Col(tagIDColumn),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ type tagRepositoryType struct {
|
|||
repository
|
||||
|
||||
aliases stringRepository
|
||||
stashIDs stashIDRepository
|
||||
|
||||
scenes joinRepository
|
||||
images joinRepository
|
||||
|
|
@ -121,6 +122,12 @@ var (
|
|||
},
|
||||
stringColumn: tagAliasColumn,
|
||||
},
|
||||
stashIDs: stashIDRepository{
|
||||
repository{
|
||||
tableName: "tag_stash_ids",
|
||||
idColumn: tagIDColumn,
|
||||
},
|
||||
},
|
||||
scenes: joinRepository{
|
||||
repository: repository{
|
||||
tableName: scenesTagsTable,
|
||||
|
|
@ -199,6 +206,12 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error {
|
|||
}
|
||||
}
|
||||
|
||||
if newObject.StashIDs.Loaded() {
|
||||
if err := tagsStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := qb.find(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding after create: %w", err)
|
||||
|
|
@ -242,6 +255,12 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta
|
|||
}
|
||||
}
|
||||
|
||||
if partial.StashIDs != nil {
|
||||
if err := tagsStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return qb.find(ctx, id)
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +290,12 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error
|
|||
}
|
||||
}
|
||||
|
||||
if updatedObject.StashIDs.Loaded() {
|
||||
if err := tagsStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +534,24 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {
|
||||
sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(
|
||||
tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID),
|
||||
tagsStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint),
|
||||
)
|
||||
|
||||
idsQuery := qb.selectDataset().Where(
|
||||
qb.table().Col(idColumn).In(sq),
|
||||
)
|
||||
|
||||
ret, err := qb.getMany(ctx, idsQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting tags for stash ID %s: %w", stashID.StashID, err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||
return tagsParentTagsTableMgr.get(ctx, relatedID)
|
||||
}
|
||||
|
|
@ -779,6 +822,14 @@ func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []stri
|
|||
return tagRepository.aliases.replace(ctx, tagID, aliases)
|
||||
}
|
||||
|
||||
func (qb *TagStore) GetStashIDs(ctx context.Context, tagID int) ([]models.StashID, error) {
|
||||
return tagsStashIDsTableMgr.get(ctx, tagID)
|
||||
}
|
||||
|
||||
func (qb *TagStore) UpdateStashIDs(ctx context.Context, tagID int, stashIDs []models.StashID) error {
|
||||
return tagsStashIDsTableMgr.replaceJoins(ctx, tagID, stashIDs)
|
||||
}
|
||||
|
||||
func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
|
|
@ -840,6 +891,19 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo
|
|||
return err
|
||||
}
|
||||
|
||||
// Merge StashIDs - move all source StashIDs to destination (ignoring duplicates)
|
||||
_, err = dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+"tag_stash_ids"+`
|
||||
SET tag_id = ?
|
||||
WHERE tag_id IN `+inBinding, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete remaining source StashIDs that couldn't be moved (duplicates)
|
||||
if _, err := dbWrapper.Exec(ctx, `DELETE FROM tag_stash_ids WHERE tag_id IN `+inBinding, srcArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range source {
|
||||
err = qb.Destroy(ctx, id)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -900,6 +900,66 @@ func TestTagUpdateAlias(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTagStashIDs(t *testing.T) {
|
||||
if err := withTxn(func(ctx context.Context) error {
|
||||
qb := db.Tag
|
||||
|
||||
// create tag to test against
|
||||
const name = "TestTagStashIDs"
|
||||
tag := models.Tag{
|
||||
Name: name,
|
||||
}
|
||||
err := qb.Create(ctx, &tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating tag: %s", err.Error())
|
||||
}
|
||||
|
||||
testStashIDReaderWriter(ctx, t, qb, tag.ID)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagFindByStashID(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
qb := db.Tag
|
||||
|
||||
// create tag to test against
|
||||
const name = "TestTagFindByStashID"
|
||||
const stashID = "stashid"
|
||||
const endpoint = "endpoint"
|
||||
tag := models.Tag{
|
||||
Name: name,
|
||||
StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}),
|
||||
}
|
||||
err := qb.Create(ctx, &tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating tag: %s", err.Error())
|
||||
}
|
||||
|
||||
// find by stash ID
|
||||
tags, err := qb.FindByStashID(ctx, models.StashID{StashID: stashID, Endpoint: endpoint})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error finding by stash ID: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, tags, 1)
|
||||
assert.Equal(t, tag.ID, tags[0].ID)
|
||||
|
||||
// find by non-existent stash ID
|
||||
tags, err = qb.FindByStashID(ctx, models.StashID{StashID: "nonexistent", Endpoint: endpoint})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error finding by stash ID: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, tags, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagMerge(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
|
|||
for _, t := range s.Tags {
|
||||
st := &models.ScrapedTag{
|
||||
Name: t.Name,
|
||||
RemoteSiteID: &t.ID,
|
||||
}
|
||||
ss.Tags = append(ss.Tags, st)
|
||||
}
|
||||
|
|
@ -242,6 +243,7 @@ type SceneDraft struct {
|
|||
Performers []*models.Performer
|
||||
// StashIDs must be loaded
|
||||
Studio *models.Studio
|
||||
// StashIDs must be loaded
|
||||
Tags []*models.Tag
|
||||
Cover []byte
|
||||
}
|
||||
|
|
@ -347,7 +349,17 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {
|
|||
var tags []*graphql.DraftEntityInput
|
||||
sceneTags := d.Tags
|
||||
for _, tag := range sceneTags {
|
||||
tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name})
|
||||
tagDraft := graphql.DraftEntityInput{Name: tag.Name}
|
||||
|
||||
stashIDs := tag.StashIDs.List()
|
||||
for _, stashID := range stashIDs {
|
||||
if stashID.Endpoint == endpoint {
|
||||
tagDraft.ID = &stashID.StashID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tags = append(tags, &tagDraft)
|
||||
}
|
||||
draft.Tags = tags
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type FinderAliasImageGetter interface {
|
|||
GetAliases(ctx context.Context, studioID int) ([]string, error)
|
||||
GetImage(ctx context.Context, tagID int) ([]byte, error)
|
||||
FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error)
|
||||
models.StashIDLoader
|
||||
}
|
||||
|
||||
// ToJSON converts a Tag object into its JSON equivalent.
|
||||
|
|
@ -37,6 +38,15 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag)
|
|||
|
||||
newTagJSON.Aliases = aliases
|
||||
|
||||
if err := tag.LoadStashIDs(ctx, reader); err != nil {
|
||||
return nil, fmt.Errorf("loading tag stash ids: %w", err)
|
||||
}
|
||||
|
||||
stashIDs := tag.StashIDs.List()
|
||||
if len(stashIDs) > 0 {
|
||||
newTagJSON.StashIDs = stashIDs
|
||||
}
|
||||
|
||||
image, err := reader.GetImage(ctx, tag.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("Error getting tag image: %v", err)
|
||||
|
|
|
|||
|
|
@ -126,6 +126,13 @@ func TestToJSON(t *testing.T) {
|
|||
db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once()
|
||||
db.Tag.On("GetAliases", testCtx, errParentsID).Return(nil, nil).Once()
|
||||
|
||||
db.Tag.On("GetStashIDs", testCtx, tagID).Return(nil, nil).Once()
|
||||
db.Tag.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once()
|
||||
db.Tag.On("GetStashIDs", testCtx, errImageID).Return(nil, nil).Once()
|
||||
// errAliasID test fails before GetStashIDs is called, so no mock needed
|
||||
db.Tag.On("GetStashIDs", testCtx, withParentsID).Return(nil, nil).Once()
|
||||
db.Tag.On("GetStashIDs", testCtx, errParentsID).Return(nil, nil).Once()
|
||||
|
||||
db.Tag.On("GetImage", testCtx, tagID).Return(imageBytes, nil).Once()
|
||||
db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once()
|
||||
db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ func (i *Importer) PreImport(ctx context.Context) error {
|
|||
Description: i.Input.Description,
|
||||
Favorite: i.Input.Favorite,
|
||||
IgnoreAutoTag: i.Input.IgnoreAutoTag,
|
||||
StashIDs: models.NewRelatedStashIDs(i.Input.StashIDs),
|
||||
CreatedAt: i.Input.CreatedAt.GetTime(),
|
||||
UpdatedAt: i.Input.UpdatedAt.GetTime(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ fragment ScrapedSceneStudioData on ScrapedStudio {
|
|||
fragment ScrapedSceneTagData on ScrapedTag {
|
||||
stored_id
|
||||
name
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
fragment ScrapedSceneData on ScrapedScene {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ fragment TagData on Tag {
|
|||
aliases
|
||||
ignore_auto_tag
|
||||
favorite
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
updated_at
|
||||
}
|
||||
image_path
|
||||
scene_count
|
||||
scene_count_all: scene_count(depth: -1)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
|||
import { getStashboxBase } from "src/utils/stashbox";
|
||||
import { ExternalLink } from "./ExternalLink";
|
||||
|
||||
export type LinkType = "performers" | "scenes" | "studios";
|
||||
export type LinkType = "performers" | "scenes" | "studios" | "tags";
|
||||
|
||||
export const StashIDPill: React.FC<{
|
||||
stashID: Pick<StashId, "endpoint" | "stash_id">;
|
||||
|
|
|
|||
|
|
@ -715,6 +715,18 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
|
||||
async function onCreateTag(t: GQL.ScrapedTag) {
|
||||
const toCreate: GQL.TagCreateInput = { name: t.name };
|
||||
|
||||
// If the tag has a remote_site_id and we have an endpoint, include the stash_id
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
if (t.remote_site_id && endpoint) {
|
||||
toCreate.stash_ids = [
|
||||
{
|
||||
endpoint: endpoint,
|
||||
stash_id: t.remote_site_id,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const newTagID = await createNewTag(t, toCreate);
|
||||
if (newTagID !== undefined) {
|
||||
setTagIDs([...tagIDs, newTagID]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { TagLink } from "src/components/Shared/TagLink";
|
||||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
import { StashIDPill } from "src/components/Shared/StashID";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
||||
interface ITagDetails {
|
||||
|
|
@ -51,6 +52,22 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
|
|||
);
|
||||
}
|
||||
|
||||
function renderStashIDs() {
|
||||
if (!tag.stash_ids?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pl-0">
|
||||
{tag.stash_ids.map((stashID) => (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
<StashIDPill stashID={stashID} linkType="tags" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-group">
|
||||
<DetailItem
|
||||
|
|
@ -68,6 +85,11 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
|
|||
value={renderChildrenField()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="stash_ids"
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useToast } from "src/hooks/Toast";
|
|||
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
|
||||
import { getStashIDs } from "src/utils/stashIds";
|
||||
import { Tag, TagSelect } from "../TagSelect";
|
||||
|
||||
interface ITagEditPanel {
|
||||
|
|
@ -52,6 +53,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
parent_ids: yup.array(yup.string().required()).defined(),
|
||||
child_ids: yup.array(yup.string().required()).defined(),
|
||||
ignore_auto_tag: yup.boolean().defined(),
|
||||
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
|
||||
image: yup.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
parent_ids: (tag?.parents ?? []).map((t) => t.id),
|
||||
child_ids: (tag?.children ?? []).map((t) => t.id),
|
||||
ignore_auto_tag: tag?.ignore_auto_tag ?? false,
|
||||
stash_ids: getStashIDs(tag?.stash_ids),
|
||||
};
|
||||
|
||||
type InputValues = yup.InferType<typeof schema>;
|
||||
|
|
@ -140,10 +143,12 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
const { renderField, renderInputField, renderStringListField } = formikUtils(
|
||||
intl,
|
||||
formik
|
||||
);
|
||||
const {
|
||||
renderField,
|
||||
renderInputField,
|
||||
renderStringListField,
|
||||
renderStashIDsField,
|
||||
} = formikUtils(intl, formik);
|
||||
|
||||
function renderParentTagsField() {
|
||||
const title = intl.formatMessage({ id: "parent_tags" });
|
||||
|
|
@ -210,6 +215,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
{renderInputField("description", "textarea")}
|
||||
{renderParentTagsField()}
|
||||
{renderSubTagsField()}
|
||||
{renderStashIDsField("stash_ids", "tags")}
|
||||
<hr />
|
||||
{renderInputField("ignore_auto_tag", "checkbox")}
|
||||
</Form>
|
||||
|
|
|
|||
Loading…
Reference in a new issue