Skip to content

Commit

Permalink
Bulk edit tags (stashapp#4925)
Browse files Browse the repository at this point in the history
* Refactor tag relationships and add bulk edit
* Add bulk edit tags dialog
  • Loading branch information
WithoutPants authored Jun 11, 2024
1 parent e18c050 commit 2d483f2
Show file tree
Hide file tree
Showing 17 changed files with 736 additions and 164 deletions.
1 change: 1 addition & 0 deletions graphql/schema/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ type Mutation {
tagDestroy(input: TagDestroyInput!): Boolean!
tagsDestroy(ids: [ID!]!): Boolean!
tagsMerge(input: TagsMergeInput!): Tag
bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!]

"""
Moves the given files to the given destination. Returns true if successful.
Expand Down
11 changes: 11 additions & 0 deletions graphql/schema/types/tag.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,14 @@ input TagsMergeInput {
source: [ID!]!
destination: ID!
}

input BulkTagUpdateInput {
ids: [ID!]
description: String
aliases: BulkUpdateStrings
ignore_auto_tag: Boolean
favorite: Boolean

parent_ids: BulkUpdateIds
child_ids: BulkUpdateIds
}
44 changes: 26 additions & 18 deletions internal/api/resolver_model_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"

"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
Expand All @@ -12,36 +13,43 @@ import (
)

func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
if !obj.ParentIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadParentIDs(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}

return ret, nil
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ParentIDs.List())
return ret, firstError(errs)
}

func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.FindByParentTagID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
if !obj.ChildIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadChildIDs(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}

return ret, nil
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ChildIDs.List())
return ret, firstError(errs)
}

func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.GetAliases(ctx, obj.ID)
return err
}); err != nil {
return nil, err
if !obj.Aliases.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadAliases(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}

return ret, err
return obj.Aliases.List(), nil
}

func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
Expand Down
175 changes: 80 additions & 95 deletions internal/api/resolver_mutation_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,21 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
newTag := models.NewTag()

newTag.Name = input.Name
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)

var err error

var parentIDs []int
if len(input.ParentIds) > 0 {
parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds)
if err != nil {
return nil, fmt.Errorf("converting parent ids: %w", err)
}
newTag.ParentIDs, err = translator.relatedIds(input.ParentIds)
if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err)
}

var childIDs []int
if len(input.ChildIds) > 0 {
childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds)
if err != nil {
return nil, fmt.Errorf("converting child ids: %w", err)
}
newTag.ChildIDs, err = translator.relatedIds(input.ChildIds)
if err != nil {
return nil, fmt.Errorf("converting child tag ids: %w", err)
}

// Process the base 64 encoded image string
Expand All @@ -68,8 +63,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag

// ensure name is unique
if err := tag.EnsureTagNameUnique(ctx, 0, newTag.Name, qb); err != nil {
if err := tag.ValidateCreate(ctx, newTag, qb); err != nil {
return err
}

Expand All @@ -85,36 +79,6 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
}
}

if len(input.Aliases) > 0 {
if err := tag.EnsureAliasesUnique(ctx, newTag.ID, input.Aliases, qb); err != nil {
return err
}

if err := qb.UpdateAliases(ctx, newTag.ID, input.Aliases); err != nil {
return err
}
}

if len(parentIDs) > 0 {
if err := qb.UpdateParentTags(ctx, newTag.ID, parentIDs); err != nil {
return err
}
}

if len(childIDs) > 0 {
if err := qb.UpdateChildTags(ctx, newTag.ID, childIDs); err != nil {
return err
}
}

// FIXME: This should be called before any changes are made, but
// requires a rewrite of ValidateHierarchy.
if len(parentIDs) > 0 || len(childIDs) > 0 {
if err := tag.ValidateHierarchy(ctx, &newTag, parentIDs, childIDs, qb); err != nil {
return err
}
}

return nil
}); err != nil {
return nil, err
Expand All @@ -137,24 +101,21 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
// Populate tag from the input
updatedTag := models.NewTagPartial()

updatedTag.Name = translator.optionalString(input.Name, "name")
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedTag.Description = translator.optionalString(input.Description, "description")

var parentIDs []int
if translator.hasField("parent_ids") {
parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds)
if err != nil {
return nil, fmt.Errorf("converting parent ids: %w", err)
}
updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases")

updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err)
}

var childIDs []int
if translator.hasField("child_ids") {
childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds)
if err != nil {
return nil, fmt.Errorf("converting child ids: %w", err)
}
updatedTag.ChildIDs, err = translator.updateIds(input.ChildIds, "child_ids")
if err != nil {
return nil, fmt.Errorf("converting child tag ids: %w", err)
}

var imageData []byte
Expand All @@ -171,24 +132,10 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag

// ensure name is unique
t, err = qb.Find(ctx, tagID)
if err != nil {
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
return err
}

if t == nil {
return fmt.Errorf("tag with id %d not found", tagID)
}

if input.Name != nil && t.Name != *input.Name {
if err := tag.EnsureTagNameUnique(ctx, tagID, *input.Name, qb); err != nil {
return err
}

updatedTag.Name = models.NewOptionalString(*input.Name)
}

t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
if err != nil {
return err
Expand All @@ -201,44 +148,82 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
}
}

if translator.hasField("aliases") {
if err := tag.EnsureAliasesUnique(ctx, tagID, input.Aliases, qb); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}

if err := qb.UpdateAliases(ctx, tagID, input.Aliases); err != nil {
return err
}
}
r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields())
return r.getTag(ctx, t.ID)
}

if parentIDs != nil {
if err := qb.UpdateParentTags(ctx, tagID, parentIDs); err != nil {
return err
}
}
func (r *mutationResolver) BulkTagUpdate(ctx context.Context, input BulkTagUpdateInput) ([]*models.Tag, error) {
tagIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, fmt.Errorf("converting ids: %w", err)
}

translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}

// Populate scene from the input
updatedTag := models.NewTagPartial()

updatedTag.Description = translator.optionalString(input.Description, "description")
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")

updatedTag.Aliases = translator.updateStringsBulk(input.Aliases, "aliases")

updatedTag.ParentIDs, err = translator.updateIdsBulk(input.ParentIds, "parent_ids")
if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err)
}

updatedTag.ChildIDs, err = translator.updateIdsBulk(input.ChildIds, "child_ids")
if err != nil {
return nil, fmt.Errorf("converting child tag ids: %w", err)
}

ret := []*models.Tag{}

// Start the transaction and save the scenes
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag

if childIDs != nil {
if err := qb.UpdateChildTags(ctx, tagID, childIDs); err != nil {
for _, tagID := range tagIDs {
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
return err
}
}

// FIXME: This should be called before any changes are made, but
// requires a rewrite of ValidateHierarchy.
if parentIDs != nil || childIDs != nil {
if err := tag.ValidateHierarchy(ctx, t, parentIDs, childIDs, qb); err != nil {
logger.Errorf("Error saving tag: %s", err)
tag, err := qb.UpdatePartial(ctx, tagID, updatedTag)
if err != nil {
return err
}

ret = append(ret, tag)
}

return nil
}); err != nil {
return nil, err
}

r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields())
return r.getTag(ctx, t.ID)
// execute post hooks outside of txn
var newRet []*models.Tag
for _, tag := range ret {
r.hookExecutor.ExecutePostHooks(ctx, tag.ID, hook.TagUpdatePost, input, translator.getFields())

tag, err = r.getTag(ctx, tag.ID)
if err != nil {
return nil, err
}

newRet = append(newRet, tag)
}

return newRet, nil
}

func (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput) (bool, error) {
Expand Down Expand Up @@ -331,7 +316,7 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return err
}

err = tag.ValidateHierarchy(ctx, t, parents, children, qb)
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
if err != nil {
logger.Errorf("Error merging tag: %s", err)
return err
Expand Down
Loading

0 comments on commit 2d483f2

Please sign in to comment.