mirror of
https://github.com/stashapp/stash.git
synced 2025-12-09 09:53:40 +01:00
Bulk edit tags (#4925)
* Refactor tag relationships and add bulk edit * Add bulk edit tags dialog
This commit is contained in:
parent
e18c050fb1
commit
2d483f2d11
17 changed files with 744 additions and 172 deletions
|
|
@ -325,6 +325,7 @@ type Mutation {
|
||||||
tagDestroy(input: TagDestroyInput!): Boolean!
|
tagDestroy(input: TagDestroyInput!): Boolean!
|
||||||
tagsDestroy(ids: [ID!]!): Boolean!
|
tagsDestroy(ids: [ID!]!): Boolean!
|
||||||
tagsMerge(input: TagsMergeInput!): Tag
|
tagsMerge(input: TagsMergeInput!): Tag
|
||||||
|
bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Moves the given files to the given destination. Returns true if successful.
|
Moves the given files to the given destination. Returns true if successful.
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,14 @@ input TagsMergeInput {
|
||||||
source: [ID!]!
|
source: [ID!]!
|
||||||
destination: ID!
|
destination: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input BulkTagUpdateInput {
|
||||||
|
ids: [ID!]
|
||||||
|
description: String
|
||||||
|
aliases: BulkUpdateStrings
|
||||||
|
ignore_auto_tag: Boolean
|
||||||
|
favorite: Boolean
|
||||||
|
|
||||||
|
parent_ids: BulkUpdateIds
|
||||||
|
child_ids: BulkUpdateIds
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/internal/api/loaders"
|
||||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||||
"github.com/stashapp/stash/pkg/gallery"
|
"github.com/stashapp/stash/pkg/gallery"
|
||||||
"github.com/stashapp/stash/pkg/image"
|
"github.com/stashapp/stash/pkg/image"
|
||||||
|
|
@ -12,36 +13,43 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
|
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 {
|
if !obj.ParentIDs.Loaded() {
|
||||||
ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID)
|
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||||
return err
|
return obj.LoadParentIDs(ctx, r.repository.Tag)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if !obj.ChildIDs.Loaded() {
|
||||||
ret, err = r.repository.Tag.FindByParentTagID(ctx, obj.ID)
|
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||||
return err
|
return obj.LoadChildIDs(ctx, r.repository.Tag)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {
|
||||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
if !obj.Aliases.Loaded() {
|
||||||
ret, err = r.repository.Tag.GetAliases(ctx, obj.ID)
|
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||||
return err
|
return obj.LoadAliases(ctx, r.repository.Tag)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
|
||||||
|
|
|
||||||
|
|
@ -33,26 +33,21 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||||
newTag := models.NewTag()
|
newTag := models.NewTag()
|
||||||
|
|
||||||
newTag.Name = input.Name
|
newTag.Name = input.Name
|
||||||
|
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
|
||||||
newTag.Favorite = translator.bool(input.Favorite)
|
newTag.Favorite = translator.bool(input.Favorite)
|
||||||
newTag.Description = translator.string(input.Description)
|
newTag.Description = translator.string(input.Description)
|
||||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
var parentIDs []int
|
newTag.ParentIDs, err = translator.relatedIds(input.ParentIds)
|
||||||
if len(input.ParentIds) > 0 {
|
if err != nil {
|
||||||
parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds)
|
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("converting parent ids: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var childIDs []int
|
newTag.ChildIDs, err = translator.relatedIds(input.ChildIds)
|
||||||
if len(input.ChildIds) > 0 {
|
if err != nil {
|
||||||
childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds)
|
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the base 64 encoded image string
|
// Process the base 64 encoded image string
|
||||||
|
|
@ -68,8 +63,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||||
qb := r.repository.Tag
|
qb := r.repository.Tag
|
||||||
|
|
||||||
// ensure name is unique
|
if err := tag.ValidateCreate(ctx, newTag, qb); err != nil {
|
||||||
if err := tag.EnsureTagNameUnique(ctx, 0, newTag.Name, qb); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -137,24 +101,21 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||||
// Populate tag from the input
|
// Populate tag from the input
|
||||||
updatedTag := models.NewTagPartial()
|
updatedTag := models.NewTagPartial()
|
||||||
|
|
||||||
|
updatedTag.Name = translator.optionalString(input.Name, "name")
|
||||||
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||||
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||||
updatedTag.Description = translator.optionalString(input.Description, "description")
|
updatedTag.Description = translator.optionalString(input.Description, "description")
|
||||||
|
|
||||||
var parentIDs []int
|
updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases")
|
||||||
if translator.hasField("parent_ids") {
|
|
||||||
parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds)
|
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("converting parent ids: %w", err)
|
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var childIDs []int
|
updatedTag.ChildIDs, err = translator.updateIds(input.ChildIds, "child_ids")
|
||||||
if translator.hasField("child_ids") {
|
if err != nil {
|
||||||
childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds)
|
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageData []byte
|
var imageData []byte
|
||||||
|
|
@ -171,24 +132,10 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||||
qb := r.repository.Tag
|
qb := r.repository.Tag
|
||||||
|
|
||||||
// ensure name is unique
|
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||||
t, err = qb.Find(ctx, tagID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
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)
|
t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -201,37 +148,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := qb.UpdateAliases(ctx, tagID, input.Aliases); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parentIDs != nil {
|
|
||||||
if err := qb.UpdateParentTags(ctx, tagID, parentIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if childIDs != nil {
|
|
||||||
if err := qb.UpdateChildTags(ctx, tagID, childIDs); 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)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -241,6 +157,75 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||||
return r.getTag(ctx, t.ID)
|
return r.getTag(ctx, t.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := qb.UpdatePartial(ctx, tagID, updatedTag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = append(ret, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput) (bool, error) {
|
||||||
tagID, err := strconv.Atoi(input.ID)
|
tagID, err := strconv.Atoi(input.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -331,7 +316,7 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tag.ValidateHierarchy(ctx, t, parents, children, qb)
|
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Error merging tag: %s", err)
|
logger.Errorf("Error merging tag: %s", err)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,29 @@ func (_m *TagReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]str
|
||||||
return r0, r1
|
return r0, r1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetChildIDs provides a mock function with given fields: ctx, relatedID
|
||||||
|
func (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||||
|
ret := _m.Called(ctx, relatedID)
|
||||||
|
|
||||||
|
var r0 []int
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {
|
||||||
|
r0 = rf(ctx, relatedID)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).([]int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// GetImage provides a mock function with given fields: ctx, tagID
|
// GetImage provides a mock function with given fields: ctx, tagID
|
||||||
func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) {
|
func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) {
|
||||||
ret := _m.Called(ctx, tagID)
|
ret := _m.Called(ctx, tagID)
|
||||||
|
|
@ -473,6 +496,29 @@ func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, err
|
||||||
return r0, r1
|
return r0, r1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetParentIDs provides a mock function with given fields: ctx, relatedID
|
||||||
|
func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||||
|
ret := _m.Called(ctx, relatedID)
|
||||||
|
|
||||||
|
var r0 []int
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {
|
||||||
|
r0 = rf(ctx, relatedID)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).([]int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// HasImage provides a mock function with given fields: ctx, tagID
|
||||||
func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {
|
func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {
|
||||||
ret := _m.Called(ctx, tagID)
|
ret := _m.Called(ctx, tagID)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,6 +13,10 @@ type Tag struct {
|
||||||
IgnoreAutoTag bool `json:"ignore_auto_tag"`
|
IgnoreAutoTag bool `json:"ignore_auto_tag"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
Aliases RelatedStrings `json:"aliases"`
|
||||||
|
ParentIDs RelatedIDs `json:"parent_ids"`
|
||||||
|
ChildIDs RelatedIDs `json:"tag_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTag() Tag {
|
func NewTag() Tag {
|
||||||
|
|
@ -22,6 +27,24 @@ func NewTag() Tag {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Tag) LoadAliases(ctx context.Context, l AliasLoader) error {
|
||||||
|
return s.Aliases.load(func() ([]string, error) {
|
||||||
|
return l.GetAliases(ctx, s.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Tag) LoadParentIDs(ctx context.Context, l TagRelationLoader) error {
|
||||||
|
return s.ParentIDs.load(func() ([]int, error) {
|
||||||
|
return l.GetParentIDs(ctx, s.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error {
|
||||||
|
return s.ChildIDs.load(func() ([]int, error) {
|
||||||
|
return l.GetChildIDs(ctx, s.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type TagPartial struct {
|
type TagPartial struct {
|
||||||
Name OptionalString
|
Name OptionalString
|
||||||
Description OptionalString
|
Description OptionalString
|
||||||
|
|
@ -29,6 +52,10 @@ type TagPartial struct {
|
||||||
IgnoreAutoTag OptionalBool
|
IgnoreAutoTag OptionalBool
|
||||||
CreatedAt OptionalTime
|
CreatedAt OptionalTime
|
||||||
UpdatedAt OptionalTime
|
UpdatedAt OptionalTime
|
||||||
|
|
||||||
|
Aliases *UpdateStrings
|
||||||
|
ParentIDs *UpdateIDs
|
||||||
|
ChildIDs *UpdateIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTagPartial() TagPartial {
|
func NewTagPartial() TagPartial {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ type TagIDLoader interface {
|
||||||
GetTagIDs(ctx context.Context, relatedID int) ([]int, error)
|
GetTagIDs(ctx context.Context, relatedID int) ([]int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagRelationLoader interface {
|
||||||
|
GetParentIDs(ctx context.Context, relatedID int) ([]int, error)
|
||||||
|
GetChildIDs(ctx context.Context, relatedID int) ([]int, error)
|
||||||
|
}
|
||||||
|
|
||||||
type FileIDLoader interface {
|
type FileIDLoader interface {
|
||||||
GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error)
|
GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ type TagReader interface {
|
||||||
TagCounter
|
TagCounter
|
||||||
|
|
||||||
AliasLoader
|
AliasLoader
|
||||||
|
TagRelationLoader
|
||||||
|
|
||||||
All(ctx context.Context) ([]*Tag, error)
|
All(ctx context.Context) ([]*Tag, error)
|
||||||
GetImage(ctx context.Context, tagID int) ([]byte, error)
|
GetImage(ctx context.Context, tagID int) ([]byte, error)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ var (
|
||||||
studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
|
studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
|
||||||
|
|
||||||
moviesURLsJoinTable = goqu.T(movieURLsTable)
|
moviesURLsJoinTable = goqu.T(movieURLsTable)
|
||||||
|
|
||||||
|
tagsAliasesJoinTable = goqu.T(tagAliasesTable)
|
||||||
|
tagRelationsJoinTable = goqu.T(tagRelationsTable)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -294,6 +297,24 @@ var (
|
||||||
table: goqu.T(tagTable),
|
table: goqu.T(tagTable),
|
||||||
idColumn: goqu.T(tagTable).Col(idColumn),
|
idColumn: goqu.T(tagTable).Col(idColumn),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagsAliasesTableMgr = &stringTable{
|
||||||
|
table: table{
|
||||||
|
table: tagsAliasesJoinTable,
|
||||||
|
idColumn: tagsAliasesJoinTable.Col(tagIDColumn),
|
||||||
|
},
|
||||||
|
stringColumn: tagsAliasesJoinTable.Col(tagAliasColumn),
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsParentTagsTableMgr = &joinTable{
|
||||||
|
table: table{
|
||||||
|
table: tagRelationsJoinTable,
|
||||||
|
idColumn: tagRelationsJoinTable.Col(tagChildIDColumn),
|
||||||
|
},
|
||||||
|
fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn),
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ const (
|
||||||
tagAliasColumn = "alias"
|
tagAliasColumn = "alias"
|
||||||
|
|
||||||
tagImageBlobColumn = "image_blob"
|
tagImageBlobColumn = "image_blob"
|
||||||
|
|
||||||
|
tagRelationsTable = "tags_relations"
|
||||||
|
tagParentIDColumn = "parent_id"
|
||||||
|
tagChildIDColumn = "child_id"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tagRow struct {
|
type tagRow struct {
|
||||||
|
|
@ -173,6 +177,24 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newObject.Aliases.Loaded() {
|
||||||
|
if err := tagsAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newObject.ParentIDs.Loaded() {
|
||||||
|
if err := tagsParentTagsTableMgr.insertJoins(ctx, id, newObject.ParentIDs.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newObject.ChildIDs.Loaded() {
|
||||||
|
if err := tagsChildTagsTableMgr.insertJoins(ctx, id, newObject.ChildIDs.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := qb.find(ctx, id)
|
updated, err := qb.find(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("finding after create: %w", err)
|
return fmt.Errorf("finding after create: %w", err)
|
||||||
|
|
@ -198,6 +220,24 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if partial.Aliases != nil {
|
||||||
|
if err := tagsAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if partial.ParentIDs != nil {
|
||||||
|
if err := tagsParentTagsTableMgr.modifyJoins(ctx, id, partial.ParentIDs.IDs, partial.ParentIDs.Mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if partial.ChildIDs != nil {
|
||||||
|
if err := tagsChildTagsTableMgr.modifyJoins(ctx, id, partial.ChildIDs.IDs, partial.ChildIDs.Mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return qb.find(ctx, id)
|
return qb.find(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +249,24 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if updatedObject.Aliases.Loaded() {
|
||||||
|
if err := tagsAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedObject.ParentIDs.Loaded() {
|
||||||
|
if err := tagsParentTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ParentIDs.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedObject.ChildIDs.Loaded() {
|
||||||
|
if err := tagsChildTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ChildIDs.List()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -423,6 +481,14 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||||
|
return tagsParentTagsTableMgr.get(ctx, relatedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *TagStore) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||||
|
return tagsChildTagsTableMgr.get(ctx, relatedID)
|
||||||
|
}
|
||||||
|
|
||||||
func (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) {
|
func (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT tags.* FROM tags
|
SELECT tags.* FROM tags
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ type InvalidTagHierarchyError struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *InvalidTagHierarchyError) Error() string {
|
func (e *InvalidTagHierarchyError) Error() string {
|
||||||
|
if e.ApplyingTag == "" {
|
||||||
|
return fmt.Sprintf("cannot apply tag \"%s\" as a %s of tag as it is already %s", e.InvalidTag, e.Direction, e.CurrentRelation)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("cannot apply tag \"%s\" as a %s of \"%s\" as it is already %s (%s)", e.InvalidTag, e.Direction, e.ApplyingTag, e.CurrentRelation, e.TagPath)
|
return fmt.Sprintf("cannot apply tag \"%s\" as a %s of \"%s\" as it is already %s (%s)", e.InvalidTag, e.Direction, e.ApplyingTag, e.CurrentRelation, e.TagPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,16 +84,83 @@ func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb model
|
||||||
type RelationshipFinder interface {
|
type RelationshipFinder interface {
|
||||||
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)
|
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)
|
||||||
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)
|
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)
|
||||||
FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error)
|
models.TagRelationLoader
|
||||||
FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error {
|
func ValidateHierarchyNew(ctx context.Context, parentIDs, childIDs []int, qb RelationshipFinder) error {
|
||||||
id := tag.ID
|
|
||||||
allAncestors := make(map[int]*models.TagPath)
|
allAncestors := make(map[int]*models.TagPath)
|
||||||
allDescendants := make(map[int]*models.TagPath)
|
allDescendants := make(map[int]*models.TagPath)
|
||||||
|
|
||||||
parentsAncestors, err := qb.FindAllAncestors(ctx, id, nil)
|
for _, parentID := range parentIDs {
|
||||||
|
parentsAncestors, err := qb.FindAllAncestors(ctx, parentID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ancestorTag := range parentsAncestors {
|
||||||
|
allAncestors[ancestorTag.ID] = ancestorTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, childID := range childIDs {
|
||||||
|
childsDescendants, err := qb.FindAllDescendants(ctx, childID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, descendentTag := range childsDescendants {
|
||||||
|
allDescendants[descendentTag.ID] = descendentTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the tag is not a parent of any of its ancestors
|
||||||
|
validateParent := func(testID int) error {
|
||||||
|
if parentTag, exists := allDescendants[testID]; exists {
|
||||||
|
return &InvalidTagHierarchyError{
|
||||||
|
Direction: "parent",
|
||||||
|
CurrentRelation: "a descendant",
|
||||||
|
InvalidTag: parentTag.Name,
|
||||||
|
TagPath: parentTag.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the tag is not a child of any of its ancestors
|
||||||
|
validateChild := func(testID int) error {
|
||||||
|
if childTag, exists := allAncestors[testID]; exists {
|
||||||
|
return &InvalidTagHierarchyError{
|
||||||
|
Direction: "child",
|
||||||
|
CurrentRelation: "an ancestor",
|
||||||
|
InvalidTag: childTag.Name,
|
||||||
|
TagPath: childTag.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, parentID := range parentIDs {
|
||||||
|
if err := validateParent(parentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, childID := range childIDs {
|
||||||
|
if err := validateChild(childID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error {
|
||||||
|
allAncestors := make(map[int]*models.TagPath)
|
||||||
|
allDescendants := make(map[int]*models.TagPath)
|
||||||
|
|
||||||
|
parentsAncestors, err := qb.FindAllAncestors(ctx, tag.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +169,7 @@ func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs
|
||||||
allAncestors[ancestorTag.ID] = ancestorTag
|
allAncestors[ancestorTag.ID] = ancestorTag
|
||||||
}
|
}
|
||||||
|
|
||||||
childsDescendants, err := qb.FindAllDescendants(ctx, id, nil)
|
childsDescendants, err := qb.FindAllDescendants(ctx, tag.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -135,28 +206,6 @@ func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if parentIDs == nil {
|
|
||||||
parentTags, err := qb.FindByChildTagID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, parentTag := range parentTags {
|
|
||||||
parentIDs = append(parentIDs, parentTag.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if childIDs == nil {
|
|
||||||
childTags, err := qb.FindByParentTagID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, childTag := range childTags {
|
|
||||||
childIDs = append(childIDs, childTag.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, parentID := range parentIDs {
|
for _, parentID := range parentIDs {
|
||||||
if err := validateParent(parentID); err != nil {
|
if err := validateParent(parentID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -176,38 +225,38 @@ func MergeHierarchy(ctx context.Context, destination int, sources []int, qb Rela
|
||||||
var mergedParents, mergedChildren []int
|
var mergedParents, mergedChildren []int
|
||||||
allIds := append([]int{destination}, sources...)
|
allIds := append([]int{destination}, sources...)
|
||||||
|
|
||||||
addTo := func(mergedItems []int, tags []*models.Tag) []int {
|
addTo := func(mergedItems []int, tagIDs []int) []int {
|
||||||
Tags:
|
Tags:
|
||||||
for _, tag := range tags {
|
for _, tagID := range tagIDs {
|
||||||
// Ignore tags which are already set
|
// Ignore tags which are already set
|
||||||
for _, existingItem := range mergedItems {
|
for _, existingItem := range mergedItems {
|
||||||
if tag.ID == existingItem {
|
if tagID == existingItem {
|
||||||
continue Tags
|
continue Tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored)
|
// Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored)
|
||||||
for _, id := range allIds {
|
for _, id := range allIds {
|
||||||
if tag.ID == id {
|
if tagID == id {
|
||||||
continue Tags
|
continue Tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergedItems = append(mergedItems, tag.ID)
|
mergedItems = append(mergedItems, tagID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergedItems
|
return mergedItems
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, id := range allIds {
|
for _, id := range allIds {
|
||||||
parents, err := qb.FindByChildTagID(ctx, id)
|
parents, err := qb.GetParentIDs(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mergedParents = addTo(mergedParents, parents)
|
mergedParents = addTo(mergedParents, parents)
|
||||||
|
|
||||||
children, err := qb.FindByParentTagID(ctx, id)
|
children, err := qb.GetChildIDs(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,14 +211,11 @@ var testUniqueHierarchyCases = []testUniqueHierarchyCase{
|
||||||
|
|
||||||
func TestEnsureHierarchy(t *testing.T) {
|
func TestEnsureHierarchy(t *testing.T) {
|
||||||
for _, tc := range testUniqueHierarchyCases {
|
for _, tc := range testUniqueHierarchyCases {
|
||||||
testEnsureHierarchy(t, tc, false, false)
|
testEnsureHierarchy(t, tc)
|
||||||
testEnsureHierarchy(t, tc, true, false)
|
|
||||||
testEnsureHierarchy(t, tc, false, true)
|
|
||||||
testEnsureHierarchy(t, tc, true, true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents, queryChildren bool) {
|
func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase) {
|
||||||
db := mocks.NewDatabase()
|
db := mocks.NewDatabase()
|
||||||
|
|
||||||
var parentIDs, childIDs []int
|
var parentIDs, childIDs []int
|
||||||
|
|
@ -244,16 +241,6 @@ func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if queryParents {
|
|
||||||
parentIDs = nil
|
|
||||||
db.Tag.On("FindByChildTagID", testCtx, tc.id).Return(tc.parents, nil).Once()
|
|
||||||
}
|
|
||||||
|
|
||||||
if queryChildren {
|
|
||||||
childIDs = nil
|
|
||||||
db.Tag.On("FindByParentTagID", testCtx, tc.id).Return(tc.children, nil).Once()
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Tag.On("FindAllAncestors", testCtx, mock.AnythingOfType("int"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath {
|
db.Tag.On("FindAllAncestors", testCtx, mock.AnythingOfType("int"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath {
|
||||||
return tc.onFindAllAncestors
|
return tc.onFindAllAncestors
|
||||||
}, func(ctx context.Context, tagID int, excludeIDs []int) error {
|
}, func(ctx context.Context, tagID int, excludeIDs []int) error {
|
||||||
|
|
@ -272,7 +259,7 @@ func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents,
|
||||||
return fmt.Errorf("undefined descendants for: %d", tagID)
|
return fmt.Errorf("undefined descendants for: %d", tagID)
|
||||||
}).Maybe()
|
}).Maybe()
|
||||||
|
|
||||||
res := ValidateHierarchy(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag)
|
res := ValidateHierarchyExisting(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
|
|
||||||
102
pkg/tag/validate.go
Normal file
102
pkg/tag/validate.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNameMissing = errors.New("tag name must not be blank")
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotFoundError struct {
|
||||||
|
id int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NotFoundError) Error() string {
|
||||||
|
return fmt.Sprintf("tag with id %d not found", e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCreate(ctx context.Context, tag models.Tag, qb models.TagReader) error {
|
||||||
|
if tag.Name == "" {
|
||||||
|
return ErrNameMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureTagNameUnique(ctx, 0, tag.Name, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag.Aliases.Loaded() {
|
||||||
|
if err := EnsureAliasesUnique(ctx, tag.ID, tag.Aliases.List(), qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tag.ParentIDs.List()) > 0 || len(tag.ChildIDs.List()) > 0 {
|
||||||
|
if err := ValidateHierarchyNew(ctx, tag.ParentIDs.List(), tag.ChildIDs.List(), qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb models.TagReader) error {
|
||||||
|
existing, err := qb.Find(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
return &NotFoundError{id}
|
||||||
|
}
|
||||||
|
|
||||||
|
if partial.Name.Set {
|
||||||
|
if partial.Name.Value == "" {
|
||||||
|
return ErrNameMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureTagNameUnique(ctx, id, partial.Name.Value, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if partial.Aliases != nil {
|
||||||
|
if err := existing.LoadAliases(ctx, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EnsureAliasesUnique(ctx, id, partial.Aliases.Apply(existing.Aliases.List()), qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if partial.ParentIDs != nil || partial.ChildIDs != nil {
|
||||||
|
if err := existing.LoadParentIDs(ctx, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := existing.LoadChildIDs(ctx, qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentIDs := partial.ParentIDs
|
||||||
|
if parentIDs == nil {
|
||||||
|
parentIDs = &models.UpdateIDs{IDs: existing.ParentIDs.List(), Mode: models.RelationshipUpdateModeSet}
|
||||||
|
}
|
||||||
|
|
||||||
|
childIDs := partial.ChildIDs
|
||||||
|
if childIDs == nil {
|
||||||
|
childIDs = &models.UpdateIDs{IDs: existing.ChildIDs.List(), Mode: models.RelationshipUpdateModeSet}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateHierarchyExisting(ctx, existing, parentIDs.Apply(existing.ParentIDs.List()), childIDs.Apply(existing.ChildIDs.List()), qb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,12 @@ mutation TagUpdate($input: TagUpdateInput!) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutation BulkTagUpdate($input: BulkTagUpdateInput!) {
|
||||||
|
bulkTagUpdate(input: $input) {
|
||||||
|
...TagData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
|
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
|
||||||
tagsMerge(input: { source: $source, destination: $destination }) {
|
tagsMerge(input: { source: $source, destination: $destination }) {
|
||||||
...TagData
|
...TagData
|
||||||
|
|
|
||||||
237
ui/v2.5/src/components/Tags/EditTagsDialog.tsx
Normal file
237
ui/v2.5/src/components/Tags/EditTagsDialog.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Form } from "react-bootstrap";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { useBulkTagUpdate } from "src/core/StashService";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { ModalComponent } from "../Shared/Modal";
|
||||||
|
import { useToast } from "src/hooks/Toast";
|
||||||
|
import { MultiSet } from "../Shared/MultiSet";
|
||||||
|
import {
|
||||||
|
getAggregateState,
|
||||||
|
getAggregateStateObject,
|
||||||
|
} from "src/utils/bulkUpdate";
|
||||||
|
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||||
|
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
|
||||||
|
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
function Tags(props: {
|
||||||
|
isUpdating: boolean;
|
||||||
|
controlId: string;
|
||||||
|
messageId: string;
|
||||||
|
existingTagIds: string[] | undefined;
|
||||||
|
tagIDs: GQL.BulkUpdateIds;
|
||||||
|
setTagIDs: (value: React.SetStateAction<GQL.BulkUpdateIds>) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isUpdating,
|
||||||
|
controlId,
|
||||||
|
messageId,
|
||||||
|
existingTagIds,
|
||||||
|
tagIDs,
|
||||||
|
setTagIDs,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Group controlId={controlId}>
|
||||||
|
<Form.Label>
|
||||||
|
<FormattedMessage id={messageId} />
|
||||||
|
</Form.Label>
|
||||||
|
<MultiSet
|
||||||
|
type="tags"
|
||||||
|
disabled={isUpdating}
|
||||||
|
onUpdate={(itemIDs) =>
|
||||||
|
setTagIDs((existing) => ({ ...existing, ids: itemIDs }))
|
||||||
|
}
|
||||||
|
onSetMode={(newMode) =>
|
||||||
|
setTagIDs((existing) => ({ ...existing, mode: newMode }))
|
||||||
|
}
|
||||||
|
existingIds={existingTagIds ?? []}
|
||||||
|
ids={tagIDs.ids ?? []}
|
||||||
|
mode={tagIDs.mode}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IListOperationProps {
|
||||||
|
selected: GQL.TagDataFragment[];
|
||||||
|
onClose: (applied: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagFields = ["favorite", "description", "ignore_auto_tag"];
|
||||||
|
|
||||||
|
export const EditTagsDialog: React.FC<IListOperationProps> = (
|
||||||
|
props: IListOperationProps
|
||||||
|
) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const [parentTagIDs, setParentTagIDs_] = useState<GQL.BulkUpdateIds>({
|
||||||
|
mode: GQL.BulkUpdateIdMode.Add,
|
||||||
|
});
|
||||||
|
|
||||||
|
function setParentTagIDs(value: React.SetStateAction<GQL.BulkUpdateIds>) {
|
||||||
|
console.log(value);
|
||||||
|
setParentTagIDs_(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingParentTagIds, setExistingParentTagIds] = useState<string[]>();
|
||||||
|
|
||||||
|
const [childTagIDs, setChildTagIDs] = useState<GQL.BulkUpdateIds>({
|
||||||
|
mode: GQL.BulkUpdateIdMode.Add,
|
||||||
|
});
|
||||||
|
const [existingChildTagIds, setExistingChildTagIds] = useState<string[]>();
|
||||||
|
|
||||||
|
const [updateInput, setUpdateInput] = useState<GQL.BulkTagUpdateInput>({});
|
||||||
|
|
||||||
|
const [updateTags] = useBulkTagUpdate(getTagInput());
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
function setUpdateField(input: Partial<GQL.BulkTagUpdateInput>) {
|
||||||
|
setUpdateInput({ ...updateInput, ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagInput(): GQL.BulkTagUpdateInput {
|
||||||
|
const tagInput: GQL.BulkTagUpdateInput = {
|
||||||
|
ids: props.selected.map((tag) => {
|
||||||
|
return tag.id;
|
||||||
|
}),
|
||||||
|
...updateInput,
|
||||||
|
parent_ids: parentTagIDs,
|
||||||
|
child_ids: childTagIDs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return tagInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await updateTags();
|
||||||
|
Toast.success(
|
||||||
|
intl.formatMessage(
|
||||||
|
{ id: "toast.updated_entity" },
|
||||||
|
{
|
||||||
|
entity: intl.formatMessage({ id: "tags" }).toLocaleLowerCase(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
props.onClose(true);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateState: GQL.BulkTagUpdateInput = {};
|
||||||
|
|
||||||
|
const state = props.selected;
|
||||||
|
let updateParentTagIds: string[] = [];
|
||||||
|
let updateChildTagIds: string[] = [];
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
state.forEach((tag: GQL.TagDataFragment) => {
|
||||||
|
getAggregateStateObject(updateState, tag, tagFields, first);
|
||||||
|
|
||||||
|
const thisParents = (tag.parents ?? []).map((t) => t.id).sort();
|
||||||
|
updateParentTagIds =
|
||||||
|
getAggregateState(updateParentTagIds, thisParents, first) ?? [];
|
||||||
|
|
||||||
|
const thisChildren = (tag.children ?? []).map((t) => t.id).sort();
|
||||||
|
updateChildTagIds =
|
||||||
|
getAggregateState(updateChildTagIds, thisChildren, first) ?? [];
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
setExistingParentTagIds(updateParentTagIds);
|
||||||
|
setExistingChildTagIds(updateChildTagIds);
|
||||||
|
setUpdateInput(updateState);
|
||||||
|
}, [props.selected]);
|
||||||
|
|
||||||
|
function renderTextField(
|
||||||
|
name: string,
|
||||||
|
value: string | undefined | null,
|
||||||
|
setter: (newValue: string | undefined) => void
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Form.Group controlId={name}>
|
||||||
|
<Form.Label>
|
||||||
|
<FormattedMessage id={name} />
|
||||||
|
</Form.Label>
|
||||||
|
<BulkUpdateTextInput
|
||||||
|
value={value === null ? "" : value ?? undefined}
|
||||||
|
valueChanged={(newValue) => setter(newValue)}
|
||||||
|
unsetDisabled={props.selected.length < 2}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalComponent
|
||||||
|
dialogClassName="edit-tags-dialog"
|
||||||
|
show
|
||||||
|
icon={faPencilAlt}
|
||||||
|
header={intl.formatMessage(
|
||||||
|
{ id: "actions.edit_entity" },
|
||||||
|
{ entityType: intl.formatMessage({ id: "tags" }) }
|
||||||
|
)}
|
||||||
|
accept={{
|
||||||
|
onClick: onSave,
|
||||||
|
text: intl.formatMessage({ id: "actions.apply" }),
|
||||||
|
}}
|
||||||
|
cancel={{
|
||||||
|
onClick: () => props.onClose(false),
|
||||||
|
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||||
|
variant: "secondary",
|
||||||
|
}}
|
||||||
|
isRunning={isUpdating}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<Form.Group controlId="favorite">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
setChecked={(checked) => setUpdateField({ favorite: checked })}
|
||||||
|
checked={updateInput.favorite ?? undefined}
|
||||||
|
label={intl.formatMessage({ id: "favourite" })}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
{renderTextField("description", updateInput.description, (v) =>
|
||||||
|
setUpdateField({ description: v })
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tags
|
||||||
|
isUpdating={isUpdating}
|
||||||
|
controlId="parent-tags"
|
||||||
|
messageId="parent_tags"
|
||||||
|
existingTagIds={existingParentTagIds}
|
||||||
|
tagIDs={parentTagIDs}
|
||||||
|
setTagIDs={setParentTagIDs}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tags
|
||||||
|
isUpdating={isUpdating}
|
||||||
|
controlId="sub-tags"
|
||||||
|
messageId="sub_tags"
|
||||||
|
existingTagIds={existingChildTagIds}
|
||||||
|
tagIDs={childTagIDs}
|
||||||
|
setTagIDs={setChildTagIDs}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Group controlId="ignore-auto-tags">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
label={intl.formatMessage({ id: "ignore_auto_tag" })}
|
||||||
|
setChecked={(checked) =>
|
||||||
|
setUpdateField({ ignore_auto_tag: checked })
|
||||||
|
}
|
||||||
|
checked={updateInput.ignore_auto_tag ?? undefined}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Form>
|
||||||
|
</ModalComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -28,6 +28,7 @@ import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
import { tagRelationHook } from "../../core/tags";
|
import { tagRelationHook } from "../../core/tags";
|
||||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { TagCardGrid } from "./TagCardGrid";
|
import { TagCardGrid } from "./TagCardGrid";
|
||||||
|
import { EditTagsDialog } from "./EditTagsDialog";
|
||||||
|
|
||||||
interface ITagList {
|
interface ITagList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
|
|
@ -325,6 +326,13 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEditDialog(
|
||||||
|
selectedTags: GQL.TagDataFragment[],
|
||||||
|
onClose: (confirmed: boolean) => void
|
||||||
|
) {
|
||||||
|
return <EditTagsDialog selected={selectedTags} onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
function renderDeleteDialog(
|
function renderDeleteDialog(
|
||||||
selectedTags: GQL.TagDataFragment[],
|
selectedTags: GQL.TagDataFragment[],
|
||||||
onClose: (confirmed: boolean) => void
|
onClose: (confirmed: boolean) => void
|
||||||
|
|
@ -361,6 +369,7 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
|
||||||
addKeybinds={addKeybinds}
|
addKeybinds={addKeybinds}
|
||||||
renderContent={renderContent}
|
renderContent={renderContent}
|
||||||
renderDeleteDialog={renderDeleteDialog}
|
renderDeleteDialog={renderDeleteDialog}
|
||||||
|
renderEditDialog={renderEditDialog}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1873,6 +1873,17 @@ export const useTagUpdate = () =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useBulkTagUpdate = (input: GQL.BulkTagUpdateInput) =>
|
||||||
|
GQL.useBulkTagUpdateMutation({
|
||||||
|
variables: { input },
|
||||||
|
update(cache, result) {
|
||||||
|
if (!result.data?.bulkTagUpdate) return;
|
||||||
|
|
||||||
|
evictTypeFields(cache, tagMutationImpactedTypeFields);
|
||||||
|
evictQueries(cache, tagMutationImpactedQueries);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useTagDestroy = (input: GQL.TagDestroyInput) =>
|
export const useTagDestroy = (input: GQL.TagDestroyInput) =>
|
||||||
GQL.useTagDestroyMutation({
|
GQL.useTagDestroyMutation({
|
||||||
variables: input,
|
variables: input,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue