mirror of
https://github.com/stashapp/stash.git
synced 2026-04-17 12:31:44 +02:00
Add parent tag hierarchy support to tag tagger (#6620)
This commit is contained in:
parent
b8bd8953f7
commit
b4fab0ac48
17 changed files with 867 additions and 484 deletions
|
|
@ -73,6 +73,7 @@ type ScrapedTag {
|
|||
name: String!
|
||||
description: String
|
||||
alias_list: [String!]
|
||||
parent: ScrapedTag
|
||||
"Remote site ID, if applicable"
|
||||
remote_site_id: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ type StashBoxBatchTagInput struct {
|
|||
ExcludeFields []string `json:"exclude_fields"`
|
||||
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
|
||||
Refresh bool `json:"refresh"`
|
||||
// If batch adding studios, should their parent studios also be created?
|
||||
// If batch adding studios or tags, should their parent entities also be created?
|
||||
CreateParent bool `json:"createParent"`
|
||||
// IDs in stash of the items to update.
|
||||
// If set, names and stash_ids fields will be ignored.
|
||||
|
|
@ -749,6 +749,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI
|
|||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -769,6 +770,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
|
|||
if len(stashID) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
stashID: &stashID,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -780,6 +782,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
|
|||
if len(name) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
name: &name,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
@ -806,6 +809,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp
|
|||
for _, t := range tags {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
createParent: input.CreateParent,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -541,6 +541,7 @@ type stashBoxBatchTagTagTask struct {
|
|||
name *string
|
||||
stashID *string
|
||||
tag *models.Tag
|
||||
createParent bool
|
||||
excludedFields []string
|
||||
}
|
||||
|
||||
|
|
@ -630,7 +631,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
|
|||
result := results[0]
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
|
||||
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -638,6 +639,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {
|
||||
if parent.StoredID == nil {
|
||||
// Create new parent tag
|
||||
newParentTag := parent.ToTag(t.box.Endpoint, excluded)
|
||||
|
||||
r := instance.Repository
|
||||
err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Tag
|
||||
|
||||
if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storedID := strconv.Itoa(newParentTag.ID)
|
||||
parent.StoredID = &storedID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err)
|
||||
} else {
|
||||
logger.Infof("Created parent tag %s", parent.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Parent already exists — nothing to update for categories
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {
|
||||
// Determine the tag ID to update — either from the task's tag or from the
|
||||
// StoredID set by match.ScrapedTag (when batch adding by name and the tag
|
||||
|
|
@ -649,6 +683,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode
|
|||
tagID, _ = strconv.Atoi(*s.StoredID)
|
||||
}
|
||||
|
||||
if s.Parent != nil && t.createParent {
|
||||
if err := t.processParentTag(ctx, s.Parent, excluded); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if tagID > 0 {
|
||||
r := instance.Repository
|
||||
err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
|
|||
return
|
||||
}
|
||||
|
||||
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
|
||||
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match parent by name only (categories don't have StashDB tag IDs)
|
||||
return ScrapedTag(ctx, qb, s.Parent, "")
|
||||
}
|
||||
|
||||
// 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, stashBoxEndpoint string) error {
|
||||
|
|
|
|||
|
|
@ -471,11 +471,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
|||
|
||||
type ScrapedTag struct {
|
||||
// Set if tag matched
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
Parent *ScrapedTag `json:"parent"`
|
||||
}
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
|
@ -496,6 +497,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
|||
ret.Aliases = NewRelatedStrings(t.AliasList)
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = NewRelatedIDs([]int{parentID})
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
|
|
@ -527,6 +535,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st
|
|||
}
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = &UpdateIDs{
|
||||
IDs: []int{parentID},
|
||||
Mode: RelationshipUpdateModeAdd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
|
|
|
|||
|
|
@ -128,10 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment {
|
|||
}
|
||||
|
||||
type TagFragment struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
Aliases []string "json:\"aliases\" graphql:\"aliases\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
Aliases []string "json:\"aliases\" graphql:\"aliases\""
|
||||
Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\""
|
||||
}
|
||||
|
||||
func (t *TagFragment) GetName() string {
|
||||
|
|
@ -158,6 +159,12 @@ func (t *TagFragment) GetAliases() []string {
|
|||
}
|
||||
return t.Aliases
|
||||
}
|
||||
func (t *TagFragment) GetCategory() *TagFragment_Category {
|
||||
if t == nil {
|
||||
t = &TagFragment{}
|
||||
}
|
||||
return t.Category
|
||||
}
|
||||
|
||||
type MeasurementsFragment struct {
|
||||
BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\""
|
||||
|
|
@ -530,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -548,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -566,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -584,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -602,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
|
@ -620,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
|||
return t.Name
|
||||
}
|
||||
|
||||
type FindTag_FindTag_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindTag_FindTag_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindTag_FindTag_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type QueryTags_QueryTags_Tags_TagFragment_Category struct {
|
||||
Description *string "json:\"description,omitempty\" graphql:\"description\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string {
|
||||
if t == nil {
|
||||
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type QueryTags_QueryTags struct {
|
||||
Count int "json:\"count\" graphql:\"count\""
|
||||
Tags []*TagFragment "json:\"tags\" graphql:\"tags\""
|
||||
|
|
@ -865,6 +1047,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1003,6 +1190,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1299,6 +1491,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1435,6 +1632,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -1469,6 +1671,11 @@ fragment TagFragment on Tag {
|
|||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -72,5 +72,12 @@ func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {
|
|||
ret.AliasList = t.Aliases
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
ret.Parent = &models.ScrapedTag{
|
||||
Name: t.Category.Name,
|
||||
Description: t.Category.Description,
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag {
|
|||
name
|
||||
description
|
||||
alias_list
|
||||
parent {
|
||||
stored_id
|
||||
name
|
||||
description
|
||||
}
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
|
|
|
|||
242
ui/v2.5/src/components/Shared/BatchModals.tsx
Normal file
242
ui/v2.5/src/components/Shared/BatchModals.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IEntityWithStashIDs {
|
||||
stash_ids: { endpoint: string }[];
|
||||
}
|
||||
|
||||
interface IBatchUpdateModalProps {
|
||||
entities: IEntityWithStashIDs[];
|
||||
isIdle: boolean;
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
allCount: number | undefined;
|
||||
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
|
||||
onRefreshChange?: (refresh: boolean) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
localePrefix: string;
|
||||
entityName: string;
|
||||
countVariableName: string;
|
||||
}
|
||||
|
||||
export const BatchUpdateModal: React.FC<IBatchUpdateModalProps> = ({
|
||||
entities,
|
||||
isIdle,
|
||||
selectedEndpoint,
|
||||
allCount,
|
||||
onBatchUpdate,
|
||||
onRefreshChange,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
localePrefix,
|
||||
entityName,
|
||||
countVariableName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [queryAll, setQueryAll] = useState(false);
|
||||
const [refresh, setRefreshState] = useState(false);
|
||||
|
||||
const setRefresh = (value: boolean) => {
|
||||
setRefreshState(value);
|
||||
onRefreshChange?.(value);
|
||||
};
|
||||
|
||||
const entityCount = useMemo(() => {
|
||||
const filteredStashIDs = entities.map((e) =>
|
||||
e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
|
||||
);
|
||||
|
||||
return queryAll
|
||||
? allCount
|
||||
: filteredStashIDs.filter((s) =>
|
||||
refresh ? s.length > 0 : s.length === 0
|
||||
).length;
|
||||
}, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faTags}
|
||||
header={intl.formatMessage({
|
||||
id: `${localePrefix}.update_${entityName}s`,
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: `${localePrefix}.update_${entityName}s`,
|
||||
}),
|
||||
onClick: () => onBatchUpdate(queryAll, refresh),
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id={`${localePrefix}.${entityName}_selection`} />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="query-page"
|
||||
type="radio"
|
||||
name={`${entityName}-query`}
|
||||
label={<FormattedMessage id={`${localePrefix}.current_page`} />}
|
||||
checked={!queryAll}
|
||||
onChange={() => setQueryAll(false)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="query-all"
|
||||
type="radio"
|
||||
name={`${entityName}-query`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.query_all_${entityName}s_in_the_database`,
|
||||
})}
|
||||
checked={queryAll}
|
||||
onChange={() => setQueryAll(true)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id={`${localePrefix}.tag_status`} />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id={`untagged-${entityName}s`}
|
||||
type="radio"
|
||||
name={`${entityName}-refresh`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.untagged_${entityName}s`,
|
||||
})}
|
||||
checked={!refresh}
|
||||
onChange={() => setRefresh(false)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.updating_untagged_${entityName}s_description`}
|
||||
/>
|
||||
</Form.Text>
|
||||
<Form.Check
|
||||
id={`tagged-${entityName}s`}
|
||||
type="radio"
|
||||
name={`${entityName}-refresh`}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.refresh_tagged_${entityName}s`,
|
||||
})}
|
||||
checked={refresh}
|
||||
onChange={() => setRefresh(true)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.refreshing_will_update_the_data`}
|
||||
/>
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<div className="mt-4">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
<b>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.number_of_${entityName}s_will_be_processed`}
|
||||
values={{
|
||||
[countVariableName]: entityCount,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface IBatchAddModalProps {
|
||||
isIdle: boolean;
|
||||
onBatchAdd: (input: string) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
localePrefix: string;
|
||||
entityName: string;
|
||||
}
|
||||
|
||||
export const BatchAddModal: React.FC<IBatchAddModalProps> = ({
|
||||
isIdle,
|
||||
onBatchAdd,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
localePrefix,
|
||||
entityName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faStar}
|
||||
header={intl.formatMessage({
|
||||
id: `${localePrefix}.add_new_${entityName}s`,
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: `${localePrefix}.add_new_${entityName}s`,
|
||||
}),
|
||||
onClick: () => {
|
||||
if (inputRef.current) {
|
||||
onBatchAdd(inputRef.current.value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
as="textarea"
|
||||
ref={inputRef}
|
||||
placeholder={intl.formatMessage({
|
||||
id: `${localePrefix}.${entityName}_names_or_stashids_separated_by_comma`,
|
||||
})}
|
||||
rows={6}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage
|
||||
id={`${localePrefix}.any_names_entered_will_be_queried`}
|
||||
/>
|
||||
</Form.Text>
|
||||
<div className="mt-2">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
|
@ -38,6 +38,7 @@ export const initialConfig: ITaggerConfig = {
|
|||
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
|
||||
excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,
|
||||
createParentStudios: true,
|
||||
createParentTags: true,
|
||||
};
|
||||
|
||||
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
|
||||
|
|
@ -56,6 +57,7 @@ export interface ITaggerConfig {
|
|||
excludedStudioFields?: string[];
|
||||
excludedTagFields?: string[];
|
||||
createParentStudios: boolean;
|
||||
createParentTags: boolean;
|
||||
}
|
||||
|
||||
export const PERFORMER_FIELDS = [
|
||||
|
|
@ -85,4 +87,4 @@ export const PERFORMER_FIELDS = [
|
|||
];
|
||||
|
||||
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];
|
||||
export const TAG_FIELDS = ["name", "description", "aliases"];
|
||||
export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link";
|
|||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import {
|
||||
stashBoxStudioQuery,
|
||||
useJobsSubscribe,
|
||||
|
|
@ -25,11 +24,15 @@ import { ITaggerConfig, STUDIO_FIELDS } from "../constants";
|
|||
import StudioModal from "../scenes/StudioModal";
|
||||
import { useUpdateStudio } from "../queries";
|
||||
import { apolloError } from "src/utils";
|
||||
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
import { mergeStudioStashIDs } from "../utils";
|
||||
import { separateNamesAndStashIds } from "src/utils/stashIds";
|
||||
import { useTaggerConfig } from "../config";
|
||||
import {
|
||||
BatchUpdateModal,
|
||||
BatchAddModal,
|
||||
} from "src/components/Shared/BatchModals";
|
||||
|
||||
type JobFragment = Pick<
|
||||
GQL.Job,
|
||||
|
|
@ -38,232 +41,6 @@ type JobFragment = Pick<
|
|||
|
||||
const CLASSNAME = "StudioTagger";
|
||||
|
||||
interface IStudioBatchUpdateModal {
|
||||
studios: GQL.StudioDataFragment[];
|
||||
isIdle: boolean;
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
|
||||
studios,
|
||||
isIdle,
|
||||
selectedEndpoint,
|
||||
onBatchUpdate,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [queryAll, setQueryAll] = useState(false);
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const { data: allStudios } = GQL.useFindStudiosQuery({
|
||||
variables: {
|
||||
studio_filter: {
|
||||
stash_id_endpoint: {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
modifier: refresh
|
||||
? GQL.CriterionModifier.NotNull
|
||||
: GQL.CriterionModifier.IsNull,
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const studioCount = useMemo(() => {
|
||||
// get all stash ids for the selected endpoint
|
||||
const filteredStashIDs = studios.map((p) =>
|
||||
p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
|
||||
);
|
||||
|
||||
return queryAll
|
||||
? allStudios?.findStudios.count
|
||||
: filteredStashIDs.filter((s) =>
|
||||
// if refresh, then we filter out the studios without a stash id
|
||||
// otherwise, we want untagged studios, filtering out those with a stash id
|
||||
refresh ? s.length > 0 : s.length === 0
|
||||
).length;
|
||||
}, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faTags}
|
||||
header={intl.formatMessage({
|
||||
id: "studio_tagger.update_studios",
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: "studio_tagger.update_studios",
|
||||
}),
|
||||
onClick: () => onBatchUpdate(queryAll, refresh),
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id="studio_tagger.studio_selection" />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="query-page"
|
||||
type="radio"
|
||||
name="studio-query"
|
||||
label={<FormattedMessage id="studio_tagger.current_page" />}
|
||||
checked={!queryAll}
|
||||
onChange={() => setQueryAll(false)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="query-all"
|
||||
type="radio"
|
||||
name="studio-query"
|
||||
label={intl.formatMessage({
|
||||
id: "studio_tagger.query_all_studios_in_the_database",
|
||||
})}
|
||||
checked={queryAll}
|
||||
onChange={() => setQueryAll(true)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id="studio_tagger.tag_status" />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="untagged-studios"
|
||||
type="radio"
|
||||
name="studio-refresh"
|
||||
label={intl.formatMessage({
|
||||
id: "studio_tagger.untagged_studios",
|
||||
})}
|
||||
checked={!refresh}
|
||||
onChange={() => setRefresh(false)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.updating_untagged_studios_description" />
|
||||
</Form.Text>
|
||||
<Form.Check
|
||||
id="tagged-studios"
|
||||
type="radio"
|
||||
name="studio-refresh"
|
||||
label={intl.formatMessage({
|
||||
id: "studio_tagger.refresh_tagged_studios",
|
||||
})}
|
||||
checked={refresh}
|
||||
onChange={() => setRefresh(true)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.refreshing_will_update_the_data" />
|
||||
</Form.Text>
|
||||
<div className="mt-4">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: "studio_tagger.create_or_tag_parent_studios",
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<b>
|
||||
<FormattedMessage
|
||||
id="studio_tagger.number_of_studios_will_be_processed"
|
||||
values={{
|
||||
studio_count: studioCount,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStudioBatchAddModal {
|
||||
isIdle: boolean;
|
||||
onBatchAdd: (input: string) => void;
|
||||
batchAddParents: boolean;
|
||||
setBatchAddParents: (addParents: boolean) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const StudioBatchAddModal: React.FC<IStudioBatchAddModal> = ({
|
||||
isIdle,
|
||||
onBatchAdd,
|
||||
batchAddParents,
|
||||
setBatchAddParents,
|
||||
close,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const studioInput = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faStar}
|
||||
header={intl.formatMessage({
|
||||
id: "studio_tagger.add_new_studios",
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: "studio_tagger.add_new_studios",
|
||||
}),
|
||||
onClick: () => {
|
||||
if (studioInput.current) {
|
||||
onBatchAdd(studioInput.current.value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
as="textarea"
|
||||
ref={studioInput}
|
||||
placeholder={intl.formatMessage({
|
||||
id: "studio_tagger.studio_names_or_stashids_separated_by_comma",
|
||||
})}
|
||||
rows={6}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.any_names_entered_will_be_queried" />
|
||||
</Form.Text>
|
||||
<div className="mt-2">
|
||||
<Form.Check
|
||||
id="add-parent"
|
||||
checked={batchAddParents}
|
||||
label={intl.formatMessage({
|
||||
id: "studio_tagger.create_or_tag_parent_studios",
|
||||
})}
|
||||
onChange={() => setBatchAddParents(!batchAddParents)}
|
||||
/>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStudioTaggerListProps {
|
||||
studios: GQL.StudioDataFragment[];
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
|
|
@ -305,6 +82,24 @@ const StudioTaggerList: React.FC<IStudioTaggerListProps> = ({
|
|||
config.createParentStudios || false
|
||||
);
|
||||
|
||||
const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);
|
||||
const { data: allStudios } = GQL.useFindStudiosQuery({
|
||||
skip: !showBatchUpdate,
|
||||
variables: {
|
||||
studio_filter: {
|
||||
stash_id_endpoint: {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
modifier: batchUpdateRefresh
|
||||
? GQL.CriterionModifier.NotNull
|
||||
: GQL.CriterionModifier.IsNull,
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [error, setError] = useState<
|
||||
Record<string, { message?: string; details?: string } | undefined>
|
||||
>({});
|
||||
|
|
@ -630,24 +425,31 @@ const StudioTaggerList: React.FC<IStudioTaggerListProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
{showBatchUpdate && (
|
||||
<StudioBatchUpdateModal
|
||||
<BatchUpdateModal
|
||||
close={() => setShowBatchUpdate(false)}
|
||||
isIdle={isIdle}
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
studios={studios}
|
||||
entities={studios}
|
||||
allCount={allStudios?.findStudios.count}
|
||||
onBatchUpdate={handleBatchUpdate}
|
||||
onRefreshChange={setBatchUpdateRefresh}
|
||||
batchAddParents={batchAddParents}
|
||||
setBatchAddParents={setBatchAddParents}
|
||||
localePrefix="studio_tagger"
|
||||
entityName="studio"
|
||||
countVariableName="studio_count"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBatchAdd && (
|
||||
<StudioBatchAddModal
|
||||
<BatchAddModal
|
||||
close={() => setShowBatchAdd(false)}
|
||||
isIdle={isIdle}
|
||||
onBatchAdd={handleBatchAdd}
|
||||
batchAddParents={batchAddParents}
|
||||
setBatchAddParents={setBatchAddParents}
|
||||
localePrefix="studio_tagger"
|
||||
entityName="studio"
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto mb-3">
|
||||
|
|
|
|||
|
|
@ -287,7 +287,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.StudioTagger {
|
||||
.StudioTagger,
|
||||
.TagTagger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
|
@ -342,7 +343,8 @@
|
|||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
&-studio-search {
|
||||
&-studio-search,
|
||||
&-tag-search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import TagModal from "./TagModal";
|
|||
import { faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIntl } from "react-intl";
|
||||
import { mergeTagStashIDs } from "../utils";
|
||||
import { useTagCreate } from "src/core/StashService";
|
||||
import { apolloError } from "src/utils";
|
||||
|
||||
interface IStashSearchResultProps {
|
||||
tag: GQL.TagListDataFragment;
|
||||
|
|
@ -34,13 +36,49 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
{}
|
||||
);
|
||||
|
||||
const [createTag] = useTagCreate();
|
||||
const updateTag = useUpdateTag();
|
||||
|
||||
const handleSave = async (input: GQL.TagCreateInput) => {
|
||||
function handleSaveError(name: string, message: string) {
|
||||
setError({
|
||||
message: intl.formatMessage(
|
||||
{ id: "tag_tagger.failed_to_save_tag" },
|
||||
{ tag: name }
|
||||
),
|
||||
details:
|
||||
message === "UNIQUE constraint failed: tags.name"
|
||||
? intl.formatMessage({
|
||||
id: "tag_tagger.name_already_exists",
|
||||
})
|
||||
: message,
|
||||
});
|
||||
}
|
||||
|
||||
const handleSave = async (
|
||||
input: GQL.TagCreateInput,
|
||||
parentInput?: GQL.TagCreateInput
|
||||
) => {
|
||||
setError({});
|
||||
setModalTag(undefined);
|
||||
setSaveState("Saving tag");
|
||||
|
||||
if (parentInput) {
|
||||
setSaveState("Saving parent tag");
|
||||
|
||||
try {
|
||||
const parentRes = await createTag({
|
||||
variables: { input: parentInput },
|
||||
});
|
||||
input.parent_ids = [parentRes.data?.tagCreate?.id].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
} catch (e) {
|
||||
handleSaveError(parentInput.name, apolloError(e));
|
||||
setSaveState("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaveState("Saving tag");
|
||||
const updateData: GQL.TagUpdateInput = {
|
||||
...input,
|
||||
id: tag.id,
|
||||
|
|
@ -54,18 +92,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
const res = await updateTag(updateData);
|
||||
|
||||
if (!res?.data?.tagUpdate) {
|
||||
setError({
|
||||
message: intl.formatMessage(
|
||||
{ id: "tag_tagger.failed_to_save_tag" },
|
||||
{ tag: input.name ?? tag.name }
|
||||
),
|
||||
details:
|
||||
res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name"
|
||||
? intl.formatMessage({
|
||||
id: "tag_tagger.name_already_exists",
|
||||
})
|
||||
: res?.errors?.[0]?.message ?? "",
|
||||
});
|
||||
handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? "");
|
||||
} else {
|
||||
onTagTagged(tag);
|
||||
}
|
||||
|
|
@ -74,7 +101,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
|
||||
const tags = stashboxTags.map((p) => (
|
||||
<Button
|
||||
className="StudioTagger-studio-search-item minimal col-6"
|
||||
className="TagTagger-tag-search-item minimal col-6"
|
||||
variant="link"
|
||||
key={p.remote_site_id}
|
||||
onClick={() => setModalTag(p)}
|
||||
|
|
@ -97,7 +124,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
endpoint={endpoint}
|
||||
/>
|
||||
)}
|
||||
<div className="StudioTagger-studio-search">{tags}</div>
|
||||
<div className="TagTagger-tag-search">{tags}</div>
|
||||
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||
{error.message && (
|
||||
<div className="text-right text-danger mt-1">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
faExternalLinkAlt,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { excludeFields } from "src/utils/data";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
|
|
@ -19,7 +19,7 @@ interface ITagModalProps {
|
|||
tag: GQL.ScrapedSceneTagDataFragment;
|
||||
modalVisible: boolean;
|
||||
closeModal: () => void;
|
||||
onSave: (input: GQL.TagCreateInput) => void;
|
||||
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
|
||||
excludedTagFields?: string[];
|
||||
header: string;
|
||||
icon: IconDefinition;
|
||||
|
|
@ -47,19 +47,58 @@ const TagModal: React.FC<ITagModalProps> = ({
|
|||
[name]: !excluded[name],
|
||||
});
|
||||
|
||||
function maybeRenderField(id: string, text: string | null | undefined) {
|
||||
const [createParentTag, setCreateParentTag] = useState<boolean>(
|
||||
!!tag.parent && !tag.parent.stored_id
|
||||
);
|
||||
|
||||
// Check if a tag with the parent name already exists locally.
|
||||
// Categories don't have stash IDs, so stored_id may be null even when the
|
||||
// parent tag has already been created (e.g. by tagging a sibling tag first).
|
||||
const parentNameQuery = GQL.useFindTagsQuery({
|
||||
skip: !tag.parent || !!tag.parent.stored_id,
|
||||
variables: {
|
||||
tag_filter: {
|
||||
name: {
|
||||
value: tag.parent?.name ?? "",
|
||||
modifier: GQL.CriterionModifier.Equals,
|
||||
},
|
||||
},
|
||||
filter: { per_page: 1 },
|
||||
},
|
||||
});
|
||||
const existingParentId = parentNameQuery.data?.findTags.tags[0]?.id;
|
||||
|
||||
// If the parent already exists locally, don't offer to create it
|
||||
const sendParentTag = !existingParentId;
|
||||
|
||||
const [parentExcluded, setParentExcluded] = useState<Record<string, boolean>>(
|
||||
excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
|
||||
);
|
||||
const toggleParentField = (name: string) =>
|
||||
setParentExcluded({
|
||||
...parentExcluded,
|
||||
[name]: !parentExcluded[name],
|
||||
});
|
||||
|
||||
function maybeRenderField(
|
||||
id: string,
|
||||
text: string | null | undefined,
|
||||
isSelectable: boolean = true
|
||||
) {
|
||||
if (!text) return;
|
||||
|
||||
return (
|
||||
<div className="row no-gutters">
|
||||
<div className="col-5 studio-create-modal-field" key={id}>
|
||||
<Button
|
||||
onClick={() => toggleField(id)}
|
||||
variant="secondary"
|
||||
className={excluded[id] ? "text-muted" : "text-success"}
|
||||
>
|
||||
<Icon icon={excluded[id] ? faTimes : faCheck} />
|
||||
</Button>
|
||||
{isSelectable && (
|
||||
<Button
|
||||
onClick={() => toggleField(id)}
|
||||
variant="secondary"
|
||||
className={excluded[id] ? "text-muted" : "text-success"}
|
||||
>
|
||||
<Icon icon={excluded[id] ? faTimes : faCheck} />
|
||||
</Button>
|
||||
)}
|
||||
<strong>
|
||||
<FormattedMessage id={id} />:
|
||||
</strong>
|
||||
|
|
@ -85,15 +124,82 @@ const TagModal: React.FC<ITagModalProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function maybeRenderParentField(
|
||||
id: string,
|
||||
text: string | null | undefined,
|
||||
isSelectable: boolean = true
|
||||
) {
|
||||
if (!text) return;
|
||||
|
||||
return (
|
||||
<div className="row no-gutters">
|
||||
<div className="col-5 studio-create-modal-field" key={id}>
|
||||
{isSelectable && (
|
||||
<Button
|
||||
onClick={() => toggleParentField(id)}
|
||||
variant="secondary"
|
||||
className={parentExcluded[id] ? "text-muted" : "text-success"}
|
||||
>
|
||||
<Icon icon={parentExcluded[id] ? faTimes : faCheck} />
|
||||
</Button>
|
||||
)}
|
||||
<strong>
|
||||
<FormattedMessage id={id} />:
|
||||
</strong>
|
||||
</div>
|
||||
<TruncatedText className="col-7" text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderParentTagDetails() {
|
||||
if (!createParentTag || !tag.parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{maybeRenderParentField("name", tag.parent.name, false)}
|
||||
{maybeRenderParentField("description", tag.parent.description)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderParentTag() {
|
||||
// No parent tag, or parent already exists locally
|
||||
if (!tag.parent || tag.parent.stored_id || !sendParentTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 mt-4">
|
||||
<Form.Check
|
||||
id="create-parent"
|
||||
checked={createParentTag}
|
||||
label={intl.formatMessage({
|
||||
id: "actions.create_parent_tag",
|
||||
})}
|
||||
onChange={() => setCreateParentTag(!createParentTag)}
|
||||
/>
|
||||
</div>
|
||||
{maybeRenderParentTagDetails()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!tag.name) {
|
||||
throw new Error("tag name must be set");
|
||||
}
|
||||
|
||||
const parentId = tag.parent?.stored_id ?? existingParentId;
|
||||
|
||||
const tagData: GQL.TagCreateInput = {
|
||||
name: tag.name,
|
||||
description: tag.description ?? undefined,
|
||||
aliases: tag.alias_list?.filter((a) => a) ?? undefined,
|
||||
parent_ids: parentId ? [parentId] : undefined,
|
||||
};
|
||||
|
||||
// stashid handling code
|
||||
|
|
@ -111,7 +217,27 @@ const TagModal: React.FC<ITagModalProps> = ({
|
|||
// handle exclusions
|
||||
excludeFields(tagData, excluded);
|
||||
|
||||
onSave(tagData);
|
||||
let parentData: GQL.TagCreateInput | undefined = undefined;
|
||||
|
||||
// Categories don't have stash IDs, so we only create new parent tags
|
||||
if (
|
||||
createParentTag &&
|
||||
sendParentTag &&
|
||||
tag.parent &&
|
||||
!tag.parent.stored_id
|
||||
) {
|
||||
parentData = {
|
||||
name: tag.parent.name,
|
||||
description: tag.parent.description ?? undefined,
|
||||
};
|
||||
|
||||
// handle exclusions
|
||||
// Can't exclude parent tag name when creating a new one
|
||||
parentExcluded.name = false;
|
||||
excludeFields(parentData, parentExcluded);
|
||||
}
|
||||
|
||||
onSave(tagData, parentData);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -133,10 +259,12 @@ const TagModal: React.FC<ITagModalProps> = ({
|
|||
{maybeRenderField("name", tag.name)}
|
||||
{maybeRenderField("description", tag.description)}
|
||||
{maybeRenderField("aliases", tag.alias_list?.join(", "))}
|
||||
{maybeRenderField("parent_tags", tag.parent?.name, false)}
|
||||
{maybeRenderStashBoxLink()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{maybeRenderParentTag()}
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link";
|
|||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import {
|
||||
stashBoxTagQuery,
|
||||
useJobsSubscribe,
|
||||
|
|
@ -20,221 +19,33 @@ import StashSearchResult from "./StashSearchResult";
|
|||
import TaggerConfig from "../TaggerConfig";
|
||||
import { ITaggerConfig, TAG_FIELDS } from "../constants";
|
||||
import { useUpdateTag } from "../queries";
|
||||
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
import { mergeTagStashIDs } from "../utils";
|
||||
import { separateNamesAndStashIds } from "src/utils/stashIds";
|
||||
import { useTaggerConfig } from "../config";
|
||||
import {
|
||||
BatchUpdateModal,
|
||||
BatchAddModal,
|
||||
} from "src/components/Shared/BatchModals";
|
||||
|
||||
type JobFragment = Pick<
|
||||
GQL.Job,
|
||||
"id" | "status" | "subTasks" | "description" | "progress"
|
||||
>;
|
||||
|
||||
const CLASSNAME = "StudioTagger";
|
||||
|
||||
interface ITagBatchUpdateModal {
|
||||
tags: GQL.TagListDataFragment[];
|
||||
isIdle: boolean;
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const TagBatchUpdateModal: React.FC<ITagBatchUpdateModal> = ({
|
||||
tags,
|
||||
isIdle,
|
||||
selectedEndpoint,
|
||||
onBatchUpdate,
|
||||
close,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [queryAll, setQueryAll] = useState(false);
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const { data: allTags } = GQL.useFindTagsQuery({
|
||||
variables: {
|
||||
tag_filter: {
|
||||
stash_id_endpoint: {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
modifier: refresh
|
||||
? GQL.CriterionModifier.NotNull
|
||||
: GQL.CriterionModifier.IsNull,
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tagCount = useMemo(() => {
|
||||
const filteredStashIDs = tags.map((t) =>
|
||||
t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
|
||||
);
|
||||
|
||||
return queryAll
|
||||
? allTags?.findTags.count
|
||||
: filteredStashIDs.filter((s) =>
|
||||
refresh ? s.length > 0 : s.length === 0
|
||||
).length;
|
||||
}, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faTags}
|
||||
header={intl.formatMessage({
|
||||
id: "tag_tagger.update_tags",
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: "tag_tagger.update_tags",
|
||||
}),
|
||||
onClick: () => onBatchUpdate(queryAll, refresh),
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id="tag_tagger.tag_selection" />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="query-page"
|
||||
type="radio"
|
||||
name="tag-query"
|
||||
label={<FormattedMessage id="tag_tagger.current_page" />}
|
||||
checked={!queryAll}
|
||||
onChange={() => setQueryAll(false)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="query-all"
|
||||
type="radio"
|
||||
name="tag-query"
|
||||
label={intl.formatMessage({
|
||||
id: "tag_tagger.query_all_tags_in_the_database",
|
||||
})}
|
||||
checked={queryAll}
|
||||
onChange={() => setQueryAll(true)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h6>
|
||||
<FormattedMessage id="tag_tagger.tag_status" />
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Check
|
||||
id="untagged-tags"
|
||||
type="radio"
|
||||
name="tag-refresh"
|
||||
label={intl.formatMessage({
|
||||
id: "tag_tagger.untagged_tags",
|
||||
})}
|
||||
checked={!refresh}
|
||||
onChange={() => setRefresh(false)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="tag_tagger.updating_untagged_tags_description" />
|
||||
</Form.Text>
|
||||
<Form.Check
|
||||
id="tagged-tags"
|
||||
type="radio"
|
||||
name="tag-refresh"
|
||||
label={intl.formatMessage({
|
||||
id: "tag_tagger.refresh_tagged_tags",
|
||||
})}
|
||||
checked={refresh}
|
||||
onChange={() => setRefresh(true)}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="tag_tagger.refreshing_will_update_the_data" />
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<b>
|
||||
<FormattedMessage
|
||||
id="tag_tagger.number_of_tags_will_be_processed"
|
||||
values={{
|
||||
tag_count: tagCount,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITagBatchAddModal {
|
||||
isIdle: boolean;
|
||||
onBatchAdd: (input: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const TagBatchAddModal: React.FC<ITagBatchAddModal> = ({
|
||||
isIdle,
|
||||
onBatchAdd,
|
||||
close,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const tagInput = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faStar}
|
||||
header={intl.formatMessage({
|
||||
id: "tag_tagger.add_new_tags",
|
||||
})}
|
||||
accept={{
|
||||
text: intl.formatMessage({
|
||||
id: "tag_tagger.add_new_tags",
|
||||
}),
|
||||
onClick: () => {
|
||||
if (tagInput.current) {
|
||||
onBatchAdd(tagInput.current.value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
}}
|
||||
cancel={{
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "danger",
|
||||
onClick: () => close(),
|
||||
}}
|
||||
disabled={!isIdle}
|
||||
>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
as="textarea"
|
||||
ref={tagInput}
|
||||
placeholder={intl.formatMessage({
|
||||
id: "tag_tagger.tag_names_or_stashids_separated_by_comma",
|
||||
})}
|
||||
rows={6}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="tag_tagger.any_names_entered_will_be_queried" />
|
||||
</Form.Text>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
const CLASSNAME = "TagTagger";
|
||||
|
||||
interface ITagTaggerListProps {
|
||||
tags: GQL.TagListDataFragment[];
|
||||
selectedEndpoint: { endpoint: string; index: number };
|
||||
isIdle: boolean;
|
||||
config: ITaggerConfig;
|
||||
onBatchAdd: (tagInput: string) => void;
|
||||
onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void;
|
||||
onBatchAdd: (tagInput: string, createParent: boolean) => void;
|
||||
onBatchUpdate: (
|
||||
ids: string[] | undefined,
|
||||
refresh: boolean,
|
||||
createParent: boolean
|
||||
) => void;
|
||||
}
|
||||
|
||||
const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
||||
|
|
@ -261,6 +72,27 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
|||
|
||||
const [showBatchAdd, setShowBatchAdd] = useState(false);
|
||||
const [showBatchUpdate, setShowBatchUpdate] = useState(false);
|
||||
const [batchAddParents, setBatchAddParents] = useState(
|
||||
config.createParentTags || false
|
||||
);
|
||||
|
||||
const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);
|
||||
const { data: allTags } = GQL.useFindTagsQuery({
|
||||
skip: !showBatchUpdate,
|
||||
variables: {
|
||||
tag_filter: {
|
||||
stash_id_endpoint: {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
modifier: batchUpdateRefresh
|
||||
? GQL.CriterionModifier.NotNull
|
||||
: GQL.CriterionModifier.IsNull,
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [error, setError] = useState<
|
||||
Record<string, { message?: string; details?: string } | undefined>
|
||||
|
|
@ -360,12 +192,16 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
|||
};
|
||||
|
||||
async function handleBatchAdd(input: string) {
|
||||
onBatchAdd(input);
|
||||
onBatchAdd(input, batchAddParents);
|
||||
setShowBatchAdd(false);
|
||||
}
|
||||
|
||||
const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {
|
||||
onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh);
|
||||
onBatchUpdate(
|
||||
!queryAll ? tags.map((t) => t.id) : undefined,
|
||||
refresh,
|
||||
batchAddParents
|
||||
);
|
||||
setShowBatchUpdate(false);
|
||||
};
|
||||
|
||||
|
|
@ -451,7 +287,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
|||
|
||||
subContent = (
|
||||
<div key={tag.id}>
|
||||
<InputGroup className="StudioTagger-box-link">
|
||||
<InputGroup className="TagTagger-box-link">
|
||||
<InputGroup.Text>{link}</InputGroup.Text>
|
||||
<InputGroup.Append>
|
||||
<Button
|
||||
|
|
@ -532,20 +368,31 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
{showBatchUpdate && (
|
||||
<TagBatchUpdateModal
|
||||
<BatchUpdateModal
|
||||
close={() => setShowBatchUpdate(false)}
|
||||
isIdle={isIdle}
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
tags={tags}
|
||||
entities={tags}
|
||||
allCount={allTags?.findTags.count}
|
||||
onBatchUpdate={handleBatchUpdate}
|
||||
onRefreshChange={setBatchUpdateRefresh}
|
||||
batchAddParents={batchAddParents}
|
||||
setBatchAddParents={setBatchAddParents}
|
||||
localePrefix="tag_tagger"
|
||||
entityName="tag"
|
||||
countVariableName="tag_count"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBatchAdd && (
|
||||
<TagBatchAddModal
|
||||
<BatchAddModal
|
||||
close={() => setShowBatchAdd(false)}
|
||||
isIdle={isIdle}
|
||||
onBatchAdd={handleBatchAdd}
|
||||
batchAddParents={batchAddParents}
|
||||
setBatchAddParents={setBatchAddParents}
|
||||
localePrefix="tag_tagger"
|
||||
entityName="tag"
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto mb-3">
|
||||
|
|
@ -611,7 +458,7 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
|||
const selectedEndpoint =
|
||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
||||
|
||||
async function batchAdd(tagInput: string) {
|
||||
async function batchAdd(tagInput: string, createParent: boolean) {
|
||||
if (tagInput && selectedEndpoint) {
|
||||
const inputs = tagInput
|
||||
.split(",")
|
||||
|
|
@ -626,7 +473,7 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
|||
stash_ids: stashIds.length > 0 ? stashIds : undefined,
|
||||
endpoint: selectedEndpointIndex,
|
||||
refresh: false,
|
||||
createParent: false,
|
||||
createParent: createParent,
|
||||
exclude_fields: config?.excludedTagFields ?? [],
|
||||
});
|
||||
|
||||
|
|
@ -635,13 +482,17 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
|||
}
|
||||
}
|
||||
|
||||
async function batchUpdate(ids: string[] | undefined, refresh: boolean) {
|
||||
async function batchUpdate(
|
||||
ids: string[] | undefined,
|
||||
refresh: boolean,
|
||||
createParent: boolean
|
||||
) {
|
||||
if (selectedEndpoint) {
|
||||
const ret = await mutateStashBoxBatchTagTag({
|
||||
ids: ids,
|
||||
endpoint: selectedEndpointIndex,
|
||||
refresh,
|
||||
createParent: false,
|
||||
createParent: createParent,
|
||||
exclude_fields: config?.excludedTagFields ?? [],
|
||||
});
|
||||
|
||||
|
|
@ -721,6 +572,28 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
|||
}
|
||||
fields={TAG_FIELDS}
|
||||
entityName="tags"
|
||||
extraConfig={
|
||||
<Form.Group
|
||||
controlId="create-parent"
|
||||
className="align-items-center"
|
||||
>
|
||||
<Form.Check
|
||||
label={
|
||||
<FormattedMessage id="tag_tagger.config.create_parent_label" />
|
||||
}
|
||||
checked={config.createParentTags}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfig({
|
||||
...config,
|
||||
createParentTags: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="tag_tagger.config.create_parent_desc" />
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
}
|
||||
/>
|
||||
<TagTaggerList
|
||||
tags={tags}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"create_marker": "Create Marker",
|
||||
"create_new": "Create new",
|
||||
"create_parent_studio": "Create parent studio",
|
||||
"create_parent_tag": "Create parent tag",
|
||||
"created_entity": "Created {entity_type}: {entity_name}",
|
||||
"customise": "Customise",
|
||||
"delete": "Delete",
|
||||
|
|
@ -1605,6 +1606,11 @@
|
|||
"any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.",
|
||||
"batch_add_tags": "Batch Add Tags",
|
||||
"batch_update_tags": "Batch Update Tags",
|
||||
"config": {
|
||||
"create_parent_desc": "Create missing parent tags from stash-box categories, or tag existing parent tags with exact name matches",
|
||||
"create_parent_label": "Create parent tags"
|
||||
},
|
||||
"create_or_tag_parent_tags": "Create missing or tag existing parent tags",
|
||||
"current_page": "Current page",
|
||||
"failed_to_save_tag": "Failed to save tag \"{tag}\"",
|
||||
"name_already_exists": "Name already exists",
|
||||
|
|
|
|||
Loading…
Reference in a new issue