mirror of
https://github.com/stashapp/stash.git
synced 2026-02-28 10:15:08 +01:00
FR: Tags Tagger (#6559)
* Refactor Tagger components * condense localization * add alias and description to model and schema
This commit is contained in:
parent
14105a2d54
commit
0103fe4751
31 changed files with 1702 additions and 262 deletions
|
|
@ -583,6 +583,8 @@ type Mutation {
|
|||
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
|
||||
"Run batch studio tag task. Returns the job ID."
|
||||
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
|
||||
"Run batch tag tag task. Returns the job ID."
|
||||
stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!
|
||||
|
||||
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
|
||||
enableDLNA(input: EnableDLNAInput!): Boolean!
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ type ScrapedTag {
|
|||
"Set if tag matched"
|
||||
stored_id: ID
|
||||
name: String!
|
||||
description: String
|
||||
alias_list: [String!]
|
||||
"Remote site ID, if applicable"
|
||||
remote_site_id: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ fragment StudioFragment on Studio {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man
|
|||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input)
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {
|
||||
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -704,3 +704,133 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
|
|||
|
||||
return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j)
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
tagQuery := s.Repository.Tag
|
||||
|
||||
for _, tagID := range input.Ids {
|
||||
if id, err := strconv.Atoi(tagID); err == nil {
|
||||
t, err := tagQuery.Find(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := t.LoadStashIDs(ctx, tagQuery); err != nil {
|
||||
return fmt.Errorf("loading tag stash ids: %w", err)
|
||||
}
|
||||
|
||||
hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil
|
||||
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
|
||||
var tasks []Task
|
||||
|
||||
for i := range input.StashIDs {
|
||||
stashID := input.StashIDs[i]
|
||||
if len(stashID) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
stashID: &stashID,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for i := range input.Names {
|
||||
name := input.Names[i]
|
||||
if len(name) > 0 {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
name: &name,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
|
||||
var tasks []Task
|
||||
|
||||
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
tagQuery := s.Repository.Tag
|
||||
var tags []*models.Tag
|
||||
var err error
|
||||
|
||||
tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying tags: %v", err)
|
||||
}
|
||||
|
||||
for _, t := range tags {
|
||||
tasks = append(tasks, &stashBoxBatchTagTagTask{
|
||||
tag: t,
|
||||
box: box,
|
||||
excludedFields: input.ExcludeFields,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
|
||||
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
|
||||
logger.Infof("Initiating stash-box batch tag tag")
|
||||
|
||||
var tasks []Task
|
||||
var err error
|
||||
|
||||
switch input.getBatchTagType(false) {
|
||||
case batchTagByIds:
|
||||
tasks, err = s.batchTagTagsByIds(ctx, input, box)
|
||||
case batchTagByNamesOrStashIds:
|
||||
tasks = s.batchTagTagsByNamesOrStashIds(input, box)
|
||||
case batchTagAll:
|
||||
tasks, err = s.batchTagAllTags(ctx, input, box)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
progress.SetTotal(len(tasks))
|
||||
|
||||
logger.Infof("Starting stash-box batch operation for %d tags", len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
progress.ExecuteTask(task.GetDescription(), func() {
|
||||
task.Start(ctx)
|
||||
})
|
||||
|
||||
progress.Increment()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/stashbox"
|
||||
"github.com/stashapp/stash/pkg/studio"
|
||||
"github.com/stashapp/stash/pkg/tag"
|
||||
)
|
||||
|
||||
// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
|
||||
|
|
@ -529,3 +530,175 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
|
|||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// stashBoxBatchTagTagTask is used to tag or create tags from stash-box.
|
||||
//
|
||||
// Two modes of operation:
|
||||
// - Update existing tag: set tag to update from stash-box data
|
||||
// - Create new tag: set name or stashID to search stash-box and create locally
|
||||
type stashBoxBatchTagTagTask struct {
|
||||
box *models.StashBox
|
||||
name *string
|
||||
stashID *string
|
||||
tag *models.Tag
|
||||
excludedFields []string
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) getName() string {
|
||||
switch {
|
||||
case t.name != nil:
|
||||
return *t.name
|
||||
case t.stashID != nil:
|
||||
return *t.stashID
|
||||
case t.tag != nil:
|
||||
return t.tag.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) {
|
||||
scrapedTag, err := t.findStashBoxTag(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Error fetching tag data from stash-box: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
excluded := map[string]bool{}
|
||||
for _, field := range t.excludedFields {
|
||||
excluded[field] = true
|
||||
}
|
||||
|
||||
if scrapedTag != nil {
|
||||
t.processMatchedTag(ctx, scrapedTag, excluded)
|
||||
} else {
|
||||
logger.Infof("No match found for %s", t.getName())
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) GetDescription() string {
|
||||
return fmt.Sprintf("Tagging tag %s from stash-box", t.getName())
|
||||
}
|
||||
|
||||
func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) {
|
||||
var results []*models.ScrapedTag
|
||||
var err error
|
||||
|
||||
r := instance.Repository
|
||||
|
||||
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
|
||||
|
||||
switch {
|
||||
case t.name != nil:
|
||||
results, err = client.QueryTag(ctx, *t.name)
|
||||
case t.stashID != nil:
|
||||
results, err = client.QueryTag(ctx, *t.stashID)
|
||||
case t.tag != nil:
|
||||
var remoteID string
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
if !t.tag.StashIDs.Loaded() {
|
||||
err = t.tag.LoadStashIDs(ctx, r.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range t.tag.StashIDs.List() {
|
||||
if id.Endpoint == t.box.Endpoint {
|
||||
remoteID = id.StashID
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteID != "" {
|
||||
results, err = client.QueryTag(ctx, remoteID)
|
||||
} else {
|
||||
results, err = client.QueryTag(ctx, t.tag.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := results[0]
|
||||
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, 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
|
||||
// already exists locally).
|
||||
tagID := 0
|
||||
if t.tag != nil {
|
||||
tagID = t.tag.ID
|
||||
} else if s.StoredID != nil {
|
||||
tagID, _ = strconv.Atoi(*s.StoredID)
|
||||
}
|
||||
|
||||
if tagID > 0 {
|
||||
r := instance.Repository
|
||||
err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Tag
|
||||
|
||||
existingStashIDs, err := qb.GetStashIDs(ctx, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storedID := strconv.Itoa(tagID)
|
||||
partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs)
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to update tag %s: %v", s.Name, err)
|
||||
} else {
|
||||
logger.Infof("Updated tag %s", s.Name)
|
||||
}
|
||||
} else if s.Name != "" {
|
||||
// no existing tag, create a new one
|
||||
newTag := s.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, *newTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create tag %s: %v", s.Name, err)
|
||||
} else {
|
||||
logger.Infof("Created tag %s", s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -450,6 +450,29 @@ func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.Sta
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint
|
||||
func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, hasStashID, stashboxEndpoint)
|
||||
|
||||
var r0 []*models.Tag
|
||||
if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok {
|
||||
r0 = rf(ctx, hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok {
|
||||
r1 = rf(ctx, hasStashID, stashboxEndpoint)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByStudioID provides a mock function with given fields: ctx, studioID
|
||||
func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, studioID)
|
||||
|
|
|
|||
|
|
@ -471,9 +471,11 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
|
@ -482,6 +484,17 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
|||
currentTime := time.Now()
|
||||
ret := NewTag()
|
||||
ret.Name = t.Name
|
||||
ret.ParentIDs = NewRelatedIDs([]int{})
|
||||
ret.ChildIDs = NewRelatedIDs([]int{})
|
||||
ret.Aliases = NewRelatedStrings([]string{})
|
||||
|
||||
if t.Description != nil && !excluded["description"] {
|
||||
ret.Description = *t.Description
|
||||
}
|
||||
|
||||
if len(t.AliasList) > 0 && !excluded["aliases"] {
|
||||
ret.Aliases = NewRelatedStrings(t.AliasList)
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
|
|
@ -496,6 +509,39 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
|||
return &ret
|
||||
}
|
||||
|
||||
func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial {
|
||||
ret := NewTagPartial()
|
||||
|
||||
if t.Name != "" && !excluded["name"] {
|
||||
ret.Name = NewOptionalString(t.Name)
|
||||
}
|
||||
|
||||
if t.Description != nil && !excluded["description"] {
|
||||
ret.Description = NewOptionalString(*t.Description)
|
||||
}
|
||||
|
||||
if len(t.AliasList) > 0 && !excluded["aliases"] {
|
||||
ret.Aliases = &UpdateStrings{
|
||||
Values: t.AliasList,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
ret.StashIDs.Set(StashID{
|
||||
Endpoint: endpoint,
|
||||
StashID: *t.RemoteSiteID,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type TagFinder interface {
|
|||
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
|
||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
|
||||
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
|
||||
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
|
||||
}
|
||||
|
||||
// TagQueryer provides methods to query tags.
|
||||
|
|
|
|||
|
|
@ -597,6 +597,36 @@ func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) (
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {
|
||||
table := qb.table()
|
||||
sq := dialect.From(table).LeftJoin(
|
||||
tagsStashIDsJoinTable,
|
||||
goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))),
|
||||
).Select(table.Col(idColumn))
|
||||
|
||||
if hasStashID {
|
||||
sq = sq.Where(
|
||||
tagsStashIDsJoinTable.Col("stash_id").IsNotNull(),
|
||||
tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint),
|
||||
)
|
||||
} else {
|
||||
sq = sq.Where(
|
||||
tagsStashIDsJoinTable.Col("stash_id").IsNull(),
|
||||
)
|
||||
}
|
||||
|
||||
idsQuery := qb.selectDataset().Where(
|
||||
table.Col(idColumn).In(sq),
|
||||
)
|
||||
|
||||
ret, err := qb.getMany(ctx, idsQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||
return tagsParentTagsTableMgr.get(ctx, relatedID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,8 +128,10 @@ func (t *StudioFragment) GetImages() []*ImageFragment {
|
|||
}
|
||||
|
||||
type TagFragment struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
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\""
|
||||
}
|
||||
|
||||
func (t *TagFragment) GetName() string {
|
||||
|
|
@ -144,6 +146,18 @@ func (t *TagFragment) GetID() string {
|
|||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *TagFragment) GetDescription() *string {
|
||||
if t == nil {
|
||||
t = &TagFragment{}
|
||||
}
|
||||
return t.Description
|
||||
}
|
||||
func (t *TagFragment) GetAliases() []string {
|
||||
if t == nil {
|
||||
t = &TagFragment{}
|
||||
}
|
||||
return t.Aliases
|
||||
}
|
||||
|
||||
type MeasurementsFragment struct {
|
||||
BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\""
|
||||
|
|
@ -849,6 +863,8 @@ fragment StudioFragment on Studio {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -985,6 +1001,8 @@ fragment StudioFragment on Studio {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1279,6 +1297,8 @@ fragment StudioFragment on Studio {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
|
|
@ -1413,6 +1433,8 @@ const FindTagDocument = `query FindTag ($id: ID, $name: String) {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -1445,6 +1467,8 @@ const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) {
|
|||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,8 @@ func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTa
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
return []*models.ScrapedTag{{
|
||||
Name: tag.FindTag.Name,
|
||||
RemoteSiteID: &tag.FindTag.ID,
|
||||
}}, nil
|
||||
ret := tagFragmentToScrapedTag(*tag.FindTag)
|
||||
return []*models.ScrapedTag{ret}, nil
|
||||
}
|
||||
|
||||
func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) {
|
||||
|
|
@ -57,11 +55,22 @@ func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.Scr
|
|||
|
||||
var ret []*models.ScrapedTag
|
||||
for _, t := range result.QueryTags.Tags {
|
||||
ret = append(ret, &models.ScrapedTag{
|
||||
Name: t.Name,
|
||||
RemoteSiteID: &t.ID,
|
||||
})
|
||||
ret = append(ret, tagFragmentToScrapedTag(*t))
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {
|
||||
ret := &models.ScrapedTag{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
RemoteSiteID: &t.ID,
|
||||
}
|
||||
|
||||
if len(t.Aliases) > 0 {
|
||||
ret.AliasList = t.Aliases
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ fragment ScrapedSceneStudioData on ScrapedStudio {
|
|||
fragment ScrapedSceneTagData on ScrapedTag {
|
||||
stored_id
|
||||
name
|
||||
description
|
||||
alias_list
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) {
|
|||
stashBoxBatchStudioTag(input: $input)
|
||||
}
|
||||
|
||||
mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) {
|
||||
stashBoxBatchTagTag(input: $input)
|
||||
}
|
||||
|
||||
mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {
|
||||
submitStashBoxSceneDraft(input: $input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,7 +395,13 @@ export const ScrapedTagsRow: React.FC<
|
|||
onSelect={(items) => {
|
||||
if (onChangeFn) {
|
||||
// map the id back to stored_id
|
||||
onChangeFn(items.map((p) => ({ ...p, stored_id: p.id })));
|
||||
onChangeFn(
|
||||
items.map((p) => ({
|
||||
...p,
|
||||
stored_id: p.id,
|
||||
alias_list: p.aliases,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}}
|
||||
ids={selectValue}
|
||||
|
|
|
|||
|
|
@ -5,22 +5,25 @@ import { useIntl } from "react-intl";
|
|||
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { PERFORMER_FIELDS } from "./constants";
|
||||
|
||||
interface IProps {
|
||||
show: boolean;
|
||||
fields: string[];
|
||||
excludedFields: string[];
|
||||
onSelect: (fields: string[]) => void;
|
||||
}
|
||||
|
||||
const PerformerFieldSelect: React.FC<IProps> = ({
|
||||
const FieldSelector: React.FC<IProps> = ({
|
||||
show,
|
||||
fields,
|
||||
excludedFields,
|
||||
onSelect,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [excluded, setExcluded] = useState<Record<string, boolean>>(
|
||||
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
|
||||
excludedFields
|
||||
.filter((field) => fields.includes(field))
|
||||
.reduce((dict, field) => ({ ...dict, [field]: true }), {})
|
||||
);
|
||||
|
||||
const toggleField = (field: string) =>
|
||||
|
|
@ -57,9 +60,9 @@ const PerformerFieldSelect: React.FC<IProps> = ({
|
|||
<div className="mb-2">
|
||||
These fields will be tagged by default. Click the button to toggle.
|
||||
</div>
|
||||
<Row>{PERFORMER_FIELDS.map((f) => renderField(f))}</Row>
|
||||
<Row>{fields.map((f) => renderField(f))}</Row>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformerFieldSelect;
|
||||
export default FieldSelector;
|
||||
|
|
@ -3,21 +3,33 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
|
|||
import { FormattedMessage } from "react-intl";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
import { ITaggerConfig } from "../constants";
|
||||
import PerformerFieldSelector from "../PerformerFieldSelector";
|
||||
import { ITaggerConfig } from "./constants";
|
||||
import FieldSelector from "./FieldSelector";
|
||||
|
||||
interface IConfigProps {
|
||||
interface ITaggerConfigProps {
|
||||
show: boolean;
|
||||
config: ITaggerConfig;
|
||||
setConfig: Dispatch<ITaggerConfig>;
|
||||
excludedFields: string[];
|
||||
onFieldsChange: (fields: string[]) => void;
|
||||
fields: string[];
|
||||
entityName: string;
|
||||
extraConfig?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
||||
const TaggerConfig: React.FC<ITaggerConfigProps> = ({
|
||||
show,
|
||||
config,
|
||||
setConfig,
|
||||
excludedFields,
|
||||
onFieldsChange,
|
||||
fields,
|
||||
entityName,
|
||||
extraConfig,
|
||||
}) => {
|
||||
const { configuration: stashConfig } = useConfigurationContext();
|
||||
const [showExclusionModal, setShowExclusionModal] = useState(false);
|
||||
|
||||
const excludedFields = config.excludedPerformerFields ?? [];
|
||||
|
||||
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedEndpoint = e.currentTarget.value;
|
||||
setConfig({
|
||||
|
|
@ -28,8 +40,8 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
|
||||
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
|
||||
|
||||
const handleFieldSelect = (fields: string[]) => {
|
||||
setConfig({ ...config, excludedPerformerFields: fields });
|
||||
const handleFieldSelect = (selectedFields: string[]) => {
|
||||
onFieldsChange(selectedFields);
|
||||
setShowExclusionModal(false);
|
||||
};
|
||||
|
||||
|
|
@ -43,9 +55,10 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
</h4>
|
||||
<hr className="w-100" />
|
||||
<div className="col-md-6">
|
||||
<Form.Group controlId="excluded-performer-fields">
|
||||
{extraConfig}
|
||||
<Form.Group controlId="excluded-fields">
|
||||
<h6>
|
||||
<FormattedMessage id="performer_tagger.config.excluded_fields" />
|
||||
<FormattedMessage id="tagger.config.excluded_fields" />
|
||||
</h6>
|
||||
<span>
|
||||
{excludedFields.length > 0 ? (
|
||||
|
|
@ -55,17 +68,20 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<FormattedMessage id="performer_tagger.config.no_fields_are_excluded" />
|
||||
<FormattedMessage id="tagger.config.no_fields_are_excluded" />
|
||||
)}
|
||||
</span>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="performer_tagger.config.these_fields_will_not_be_changed_when_updating_performers" />
|
||||
<FormattedMessage
|
||||
id="tagger.config.fields_will_not_be_changed"
|
||||
values={{ entity: entityName }}
|
||||
/>
|
||||
</Form.Text>
|
||||
<Button
|
||||
onClick={() => setShowExclusionModal(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
<FormattedMessage id="performer_tagger.config.edit_excluded_fields" />
|
||||
<FormattedMessage id="tagger.config.edit_excluded_fields" />
|
||||
</Button>
|
||||
</Form.Group>
|
||||
<Form.Group
|
||||
|
|
@ -73,7 +89,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
className="align-items-center row no-gutters mt-4"
|
||||
>
|
||||
<Form.Label className="mr-4">
|
||||
<FormattedMessage id="performer_tagger.config.active_stash-box_instance" />
|
||||
<FormattedMessage id="tagger.config.active_stash-box_instance" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
|
|
@ -84,7 +100,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
>
|
||||
{!stashBoxes.length && (
|
||||
<option>
|
||||
<FormattedMessage id="performer_tagger.config.no_instances_found" />
|
||||
<FormattedMessage id="tagger.config.no_instances_found" />
|
||||
</option>
|
||||
)}
|
||||
{stashConfig?.general.stashBoxes.map((i) => (
|
||||
|
|
@ -98,8 +114,9 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
<PerformerFieldSelector
|
||||
<FieldSelector
|
||||
show={showExclusionModal}
|
||||
fields={fields}
|
||||
onSelect={handleFieldSelect}
|
||||
excludedFields={excludedFields}
|
||||
/>
|
||||
|
|
@ -107,4 +124,4 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Config;
|
||||
export default TaggerConfig;
|
||||
|
|
@ -24,6 +24,7 @@ export const DEFAULT_BLACKLIST = [
|
|||
];
|
||||
export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"];
|
||||
export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"];
|
||||
export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"];
|
||||
|
||||
export const initialConfig: ITaggerConfig = {
|
||||
blacklist: DEFAULT_BLACKLIST,
|
||||
|
|
@ -35,6 +36,7 @@ export const initialConfig: ITaggerConfig = {
|
|||
excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS,
|
||||
markSceneAsOrganizedOnSave: false,
|
||||
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
|
||||
excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,
|
||||
createParentStudios: true,
|
||||
};
|
||||
|
||||
|
|
@ -52,6 +54,7 @@ export interface ITaggerConfig {
|
|||
excludedPerformerFields?: string[];
|
||||
markSceneAsOrganizedOnSave?: boolean;
|
||||
excludedStudioFields?: string[];
|
||||
excludedTagFields?: string[];
|
||||
createParentStudios: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -82,3 +85,4 @@ export const PERFORMER_FIELDS = [
|
|||
];
|
||||
|
||||
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];
|
||||
export const TAG_FIELDS = ["name", "description", "aliases"];
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import { Manual } from "src/components/Help/Manual";
|
|||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
import StashSearchResult from "./StashSearchResult";
|
||||
import PerformerConfig from "./Config";
|
||||
import { ITaggerConfig } from "../constants";
|
||||
import TaggerConfig from "../TaggerConfig";
|
||||
import { ITaggerConfig, PERFORMER_FIELDS } from "../constants";
|
||||
import PerformerModal from "../PerformerModal";
|
||||
import { useUpdatePerformer } from "../queries";
|
||||
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
|
|
@ -771,10 +771,16 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<PerformerConfig
|
||||
<TaggerConfig
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
show={showConfig}
|
||||
excludedFields={config.excludedPerformerFields ?? []}
|
||||
onFieldsChange={(fields) =>
|
||||
setConfig({ ...config, excludedPerformerFields: fields })
|
||||
}
|
||||
fields={PERFORMER_FIELDS}
|
||||
entityName="performers"
|
||||
/>
|
||||
<PerformerTaggerList
|
||||
performers={performers}
|
||||
|
|
|
|||
|
|
@ -97,3 +97,44 @@ export const useUpdateStudio = () => {
|
|||
|
||||
return updateStudioHandler;
|
||||
};
|
||||
|
||||
export const useUpdateTag = () => {
|
||||
const [updateTag] = GQL.useTagUpdateMutation({
|
||||
onError: (errors) => errors,
|
||||
errorPolicy: "all",
|
||||
});
|
||||
|
||||
const updateTagHandler = (input: GQL.TagUpdateInput) =>
|
||||
updateTag({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
update: (store, updatedTag) => {
|
||||
if (!updatedTag.data?.tagUpdate) return;
|
||||
|
||||
updatedTag.data.tagUpdate.stash_ids.forEach((id) => {
|
||||
store.writeQuery<GQL.FindTagsQuery, GQL.FindTagsQueryVariables>({
|
||||
query: GQL.FindTagsDocument,
|
||||
variables: {
|
||||
tag_filter: {
|
||||
stash_id_endpoint: {
|
||||
stash_id: id.stash_id,
|
||||
endpoint: id.endpoint,
|
||||
modifier: GQL.CriterionModifier.Equals,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
findTags: {
|
||||
count: 1,
|
||||
tags: [updatedTag.data!.tagUpdate!],
|
||||
__typename: "FindTagsResultType",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return updateTagHandler;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
import React, { Dispatch, useState } from "react";
|
||||
import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
import { ITaggerConfig } from "../constants";
|
||||
import StudioFieldSelector from "./StudioFieldSelector";
|
||||
|
||||
interface IConfigProps {
|
||||
show: boolean;
|
||||
config: ITaggerConfig;
|
||||
setConfig: Dispatch<ITaggerConfig>;
|
||||
}
|
||||
|
||||
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
||||
const { configuration: stashConfig } = useConfigurationContext();
|
||||
const [showExclusionModal, setShowExclusionModal] = useState(false);
|
||||
|
||||
const excludedFields = config.excludedStudioFields ?? [];
|
||||
|
||||
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedEndpoint = e.currentTarget.value;
|
||||
setConfig({
|
||||
...config,
|
||||
selectedEndpoint,
|
||||
});
|
||||
};
|
||||
|
||||
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
|
||||
|
||||
const handleFieldSelect = (fields: string[]) => {
|
||||
setConfig({ ...config, excludedStudioFields: fields });
|
||||
setShowExclusionModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse in={show}>
|
||||
<Card>
|
||||
<div className="row">
|
||||
<h4 className="col-12">
|
||||
<FormattedMessage id="configuration" />
|
||||
</h4>
|
||||
<hr className="w-100" />
|
||||
<div className="col-md-6">
|
||||
<Form.Group
|
||||
controlId="create-parent"
|
||||
className="align-items-center"
|
||||
>
|
||||
<Form.Check
|
||||
label={
|
||||
<FormattedMessage id="studio_tagger.config.create_parent_label" />
|
||||
}
|
||||
checked={config.createParentStudios}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfig({
|
||||
...config,
|
||||
createParentStudios: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.config.create_parent_desc" />
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="excluded-studio-fields">
|
||||
<h6>
|
||||
<FormattedMessage id="studio_tagger.config.excluded_fields" />
|
||||
</h6>
|
||||
<span>
|
||||
{excludedFields.length > 0 ? (
|
||||
excludedFields.map((f) => (
|
||||
<Badge variant="secondary" className="tag-item" key={f}>
|
||||
<FormattedMessage id={f} />
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<FormattedMessage id="studio_tagger.config.no_fields_are_excluded" />
|
||||
)}
|
||||
</span>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.config.these_fields_will_not_be_changed_when_updating_studios" />
|
||||
</Form.Text>
|
||||
<Button
|
||||
onClick={() => setShowExclusionModal(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
<FormattedMessage id="studio_tagger.config.edit_excluded_fields" />
|
||||
</Button>
|
||||
</Form.Group>
|
||||
<Form.Group
|
||||
controlId="stash-box-endpoint"
|
||||
className="align-items-center row no-gutters mt-4"
|
||||
>
|
||||
<Form.Label className="mr-4">
|
||||
<FormattedMessage id="studio_tagger.config.active_stash-box_instance" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={config.selectedEndpoint}
|
||||
className="col-md-4 col-6 input-control"
|
||||
disabled={!stashBoxes.length}
|
||||
onChange={handleInstanceSelect}
|
||||
>
|
||||
{!stashBoxes.length && (
|
||||
<option>
|
||||
<FormattedMessage id="studio_tagger.config.no_instances_found" />
|
||||
</option>
|
||||
)}
|
||||
{stashConfig?.general.stashBoxes.map((i) => (
|
||||
<option value={i.endpoint} key={i.endpoint}>
|
||||
{i.endpoint}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
<StudioFieldSelector
|
||||
show={showExclusionModal}
|
||||
onSelect={handleFieldSelect}
|
||||
excludedFields={excludedFields}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Row, Col } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { ModalComponent } from "../../Shared/Modal";
|
||||
import { Icon } from "../../Shared/Icon";
|
||||
import { STUDIO_FIELDS } from "../constants";
|
||||
|
||||
interface IProps {
|
||||
show: boolean;
|
||||
excludedFields: string[];
|
||||
onSelect: (fields: string[]) => void;
|
||||
}
|
||||
|
||||
const StudioFieldSelect: React.FC<IProps> = ({
|
||||
show,
|
||||
excludedFields,
|
||||
onSelect,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [excluded, setExcluded] = useState<Record<string, boolean>>(
|
||||
// filter out fields that aren't in STUDIO_FIELDS
|
||||
excludedFields
|
||||
.filter((field) => STUDIO_FIELDS.includes(field))
|
||||
.reduce((dict, field) => ({ ...dict, [field]: true }), {})
|
||||
);
|
||||
|
||||
const toggleField = (field: string) =>
|
||||
setExcluded({
|
||||
...excluded,
|
||||
[field]: !excluded[field],
|
||||
});
|
||||
|
||||
const renderField = (field: string) => (
|
||||
<Col xs={6} className="mb-1" key={field}>
|
||||
<Button
|
||||
onClick={() => toggleField(field)}
|
||||
variant="secondary"
|
||||
className={excluded[field] ? "text-muted" : "text-success"}
|
||||
>
|
||||
<Icon icon={excluded[field] ? faTimes : faCheck} />
|
||||
</Button>
|
||||
<span className="ml-3">{intl.formatMessage({ id: field })}</span>
|
||||
</Col>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show={show}
|
||||
icon={faList}
|
||||
dialogClassName="FieldSelect"
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.save" }),
|
||||
onClick: () =>
|
||||
onSelect(Object.keys(excluded).filter((f) => excluded[f])),
|
||||
}}
|
||||
>
|
||||
<h4>Select tagged fields</h4>
|
||||
<div className="mb-2">
|
||||
These fields will be tagged by default. Click the button to toggle.
|
||||
</div>
|
||||
<Row>{STUDIO_FIELDS.map((f) => renderField(f))}</Row>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioFieldSelect;
|
||||
|
|
@ -20,8 +20,8 @@ import { Manual } from "src/components/Help/Manual";
|
|||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
import StashSearchResult from "./StashSearchResult";
|
||||
import StudioConfig from "./Config";
|
||||
import { ITaggerConfig } from "../constants";
|
||||
import TaggerConfig from "../TaggerConfig";
|
||||
import { ITaggerConfig, STUDIO_FIELDS } from "../constants";
|
||||
import StudioModal from "../scenes/StudioModal";
|
||||
import { useUpdateStudio } from "../queries";
|
||||
import { apolloError } from "src/utils";
|
||||
|
|
@ -825,10 +825,38 @@ export const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<StudioConfig
|
||||
<TaggerConfig
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
show={showConfig}
|
||||
excludedFields={config.excludedStudioFields ?? []}
|
||||
onFieldsChange={(fields) =>
|
||||
setConfig({ ...config, excludedStudioFields: fields })
|
||||
}
|
||||
fields={STUDIO_FIELDS}
|
||||
entityName="studios"
|
||||
extraConfig={
|
||||
<Form.Group
|
||||
controlId="create-parent"
|
||||
className="align-items-center"
|
||||
>
|
||||
<Form.Check
|
||||
label={
|
||||
<FormattedMessage id="studio_tagger.config.create_parent_label" />
|
||||
}
|
||||
checked={config.createParentStudios}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfig({
|
||||
...config,
|
||||
createParentStudios: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="studio_tagger.config.create_parent_desc" />
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
}
|
||||
/>
|
||||
<StudioTaggerList
|
||||
studios={studios}
|
||||
|
|
|
|||
119
ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx
Normal file
119
ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useUpdateTag } from "../queries";
|
||||
import TagModal from "./TagModal";
|
||||
import { faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIntl } from "react-intl";
|
||||
import { mergeTagStashIDs } from "../utils";
|
||||
|
||||
interface IStashSearchResultProps {
|
||||
tag: GQL.TagListDataFragment;
|
||||
stashboxTags: GQL.ScrapedSceneTagDataFragment[];
|
||||
endpoint: string;
|
||||
onTagTagged: (
|
||||
tag: Pick<GQL.TagListDataFragment, "id"> &
|
||||
Partial<Omit<GQL.TagListDataFragment, "id">>
|
||||
) => void;
|
||||
excludedTagFields: string[];
|
||||
}
|
||||
|
||||
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
tag,
|
||||
stashboxTags,
|
||||
onTagTagged,
|
||||
excludedTagFields,
|
||||
endpoint,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [modalTag, setModalTag] = useState<GQL.ScrapedSceneTagDataFragment>();
|
||||
const [saveState, setSaveState] = useState<string>("");
|
||||
const [error, setError] = useState<{ message?: string; details?: string }>(
|
||||
{}
|
||||
);
|
||||
|
||||
const updateTag = useUpdateTag();
|
||||
|
||||
const handleSave = async (input: GQL.TagCreateInput) => {
|
||||
setError({});
|
||||
setModalTag(undefined);
|
||||
setSaveState("Saving tag");
|
||||
|
||||
const updateData: GQL.TagUpdateInput = {
|
||||
...input,
|
||||
id: tag.id,
|
||||
};
|
||||
|
||||
updateData.stash_ids = await mergeTagStashIDs(
|
||||
tag.id,
|
||||
input.stash_ids ?? []
|
||||
);
|
||||
|
||||
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 ?? "",
|
||||
});
|
||||
} else {
|
||||
onTagTagged(tag);
|
||||
}
|
||||
setSaveState("");
|
||||
};
|
||||
|
||||
const tags = stashboxTags.map((p) => (
|
||||
<Button
|
||||
className="StudioTagger-studio-search-item minimal col-6"
|
||||
variant="link"
|
||||
key={p.remote_site_id}
|
||||
onClick={() => setModalTag(p)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</Button>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalTag && (
|
||||
<TagModal
|
||||
closeModal={() => setModalTag(undefined)}
|
||||
modalVisible={modalTag !== undefined}
|
||||
tag={modalTag}
|
||||
onSave={handleSave}
|
||||
icon={faTags}
|
||||
header="Update Tag"
|
||||
excludedTagFields={excludedTagFields}
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
)}
|
||||
<div className="StudioTagger-studio-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">
|
||||
<strong>
|
||||
<span className="mr-2">Error:</span>
|
||||
{error.message}
|
||||
</strong>
|
||||
<div>{error.details}</div>
|
||||
</div>
|
||||
)}
|
||||
{saveState && (
|
||||
<strong className="col-4 mt-1 mr-2 text-right">{saveState}</strong>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StashSearchResult;
|
||||
144
ui/v2.5/src/components/Tagger/tags/TagModal.tsx
Normal file
144
ui/v2.5/src/components/Tagger/tags/TagModal.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import {
|
||||
faCheck,
|
||||
faExternalLinkAlt,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { excludeFields } from "src/utils/data";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
|
||||
interface ITagModalProps {
|
||||
tag: GQL.ScrapedSceneTagDataFragment;
|
||||
modalVisible: boolean;
|
||||
closeModal: () => void;
|
||||
onSave: (input: GQL.TagCreateInput) => void;
|
||||
excludedTagFields?: string[];
|
||||
header: string;
|
||||
icon: IconDefinition;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
const TagModal: React.FC<ITagModalProps> = ({
|
||||
modalVisible,
|
||||
tag,
|
||||
onSave,
|
||||
closeModal,
|
||||
excludedTagFields = [],
|
||||
header,
|
||||
icon,
|
||||
endpoint,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [excluded, setExcluded] = useState<Record<string, boolean>>(
|
||||
excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
|
||||
);
|
||||
const toggleField = (name: string) =>
|
||||
setExcluded({
|
||||
...excluded,
|
||||
[name]: !excluded[name],
|
||||
});
|
||||
|
||||
function maybeRenderField(id: string, text: string | null | undefined) {
|
||||
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>
|
||||
<strong>
|
||||
<FormattedMessage id={id} />:
|
||||
</strong>
|
||||
</div>
|
||||
<TruncatedText className="col-7" text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderStashBoxLink() {
|
||||
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? `${base}tags/${tag.remote_site_id}` : undefined;
|
||||
|
||||
if (!link) return;
|
||||
|
||||
return (
|
||||
<h6 className="mt-2">
|
||||
<ExternalLink href={link}>
|
||||
<FormattedMessage id="stashbox.source" />
|
||||
<Icon icon={faExternalLinkAlt} className="ml-2" />
|
||||
</ExternalLink>
|
||||
</h6>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!tag.name) {
|
||||
throw new Error("tag name must be set");
|
||||
}
|
||||
|
||||
const tagData: GQL.TagCreateInput = {
|
||||
name: tag.name,
|
||||
description: tag.description ?? undefined,
|
||||
aliases: tag.alias_list?.filter((a) => a) ?? undefined,
|
||||
};
|
||||
|
||||
// stashid handling code
|
||||
const remoteSiteID = tag.remote_site_id;
|
||||
if (remoteSiteID && endpoint) {
|
||||
tagData.stash_ids = [
|
||||
{
|
||||
endpoint,
|
||||
stash_id: remoteSiteID,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// handle exclusions
|
||||
excludeFields(tagData, excluded);
|
||||
|
||||
onSave(tagData);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show={modalVisible}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.save" }),
|
||||
onClick: handleSave,
|
||||
}}
|
||||
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
|
||||
onHide={() => closeModal()}
|
||||
dialogClassName="studio-create-modal"
|
||||
icon={icon}
|
||||
header={header}
|
||||
>
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
{maybeRenderField("name", tag.name)}
|
||||
{maybeRenderField("description", tag.description)}
|
||||
{maybeRenderField("aliases", tag.alias_list?.join(", "))}
|
||||
{maybeRenderStashBoxLink()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagModal;
|
||||
758
ui/v2.5/src/components/Tagger/tags/TagTagger.tsx
Normal file
758
ui/v2.5/src/components/Tagger/tags/TagTagger.tsx
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
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,
|
||||
mutateStashBoxBatchTagTag,
|
||||
getClient,
|
||||
} from "src/core/StashService";
|
||||
import { Manual } from "src/components/Help/Manual";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
||||
tags,
|
||||
selectedEndpoint,
|
||||
isIdle,
|
||||
config,
|
||||
onBatchAdd,
|
||||
onBatchUpdate,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Record<string, GQL.ScrapedSceneTagDataFragment[]>
|
||||
>({});
|
||||
const [searchErrors, setSearchErrors] = useState<
|
||||
Record<string, string | undefined>
|
||||
>({});
|
||||
const [taggedTags, setTaggedTags] = useState<
|
||||
Record<string, Partial<GQL.TagListDataFragment>>
|
||||
>({});
|
||||
const [queries, setQueries] = useState<Record<string, string>>({});
|
||||
|
||||
const [showBatchAdd, setShowBatchAdd] = useState(false);
|
||||
const [showBatchUpdate, setShowBatchUpdate] = useState(false);
|
||||
|
||||
const [error, setError] = useState<
|
||||
Record<string, { message?: string; details?: string } | undefined>
|
||||
>({});
|
||||
const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();
|
||||
|
||||
const doBoxSearch = (tagID: string, searchVal: string) => {
|
||||
stashBoxTagQuery(searchVal, selectedEndpoint.endpoint)
|
||||
.then((queryData) => {
|
||||
const s = queryData.data?.scrapeSingleTag ?? [];
|
||||
setSearchResults({
|
||||
...searchResults,
|
||||
[tagID]: s,
|
||||
});
|
||||
setSearchErrors({
|
||||
...searchErrors,
|
||||
[tagID]: undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
const { [tagID]: unassign, ...results } = searchResults;
|
||||
setSearchResults(results);
|
||||
setSearchErrors({
|
||||
...searchErrors,
|
||||
[tagID]: intl.formatMessage({
|
||||
id: "tag_tagger.network_error",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const updateTag = useUpdateTag();
|
||||
|
||||
const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => {
|
||||
setLoadingUpdate(stashID);
|
||||
setError({
|
||||
...error,
|
||||
[tagID]: undefined,
|
||||
});
|
||||
stashBoxTagQuery(stashID, endpoint)
|
||||
.then(async (queryData) => {
|
||||
const data = queryData.data?.scrapeSingleTag ?? [];
|
||||
if (data.length > 0) {
|
||||
const stashboxTag = data[0];
|
||||
const updateData: GQL.TagUpdateInput = {
|
||||
id: tagID,
|
||||
};
|
||||
|
||||
if (
|
||||
!(config.excludedTagFields ?? []).includes("name") &&
|
||||
stashboxTag.name
|
||||
) {
|
||||
updateData.name = stashboxTag.name;
|
||||
}
|
||||
|
||||
if (
|
||||
stashboxTag.description &&
|
||||
!(config.excludedTagFields ?? []).includes("description")
|
||||
) {
|
||||
updateData.description = stashboxTag.description;
|
||||
}
|
||||
|
||||
if (
|
||||
stashboxTag.alias_list &&
|
||||
stashboxTag.alias_list.length > 0 &&
|
||||
!(config.excludedTagFields ?? []).includes("aliases")
|
||||
) {
|
||||
updateData.aliases = stashboxTag.alias_list;
|
||||
}
|
||||
|
||||
if (stashboxTag.remote_site_id) {
|
||||
updateData.stash_ids = await mergeTagStashIDs(tagID, [
|
||||
{
|
||||
endpoint,
|
||||
stash_id: stashboxTag.remote_site_id,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const res = await updateTag(updateData);
|
||||
if (!res?.data?.tagUpdate) {
|
||||
setError({
|
||||
...error,
|
||||
[tagID]: {
|
||||
message: `Failed to update tag`,
|
||||
details: res?.errors?.[0]?.message ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingUpdate(undefined));
|
||||
};
|
||||
|
||||
async function handleBatchAdd(input: string) {
|
||||
onBatchAdd(input);
|
||||
setShowBatchAdd(false);
|
||||
}
|
||||
|
||||
const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {
|
||||
onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh);
|
||||
setShowBatchUpdate(false);
|
||||
};
|
||||
|
||||
const handleTaggedTag = (
|
||||
tag: Pick<GQL.TagListDataFragment, "id"> &
|
||||
Partial<Omit<GQL.TagListDataFragment, "id">>
|
||||
) => {
|
||||
setTaggedTags({
|
||||
...taggedTags,
|
||||
[tag.id]: tag,
|
||||
});
|
||||
};
|
||||
|
||||
const renderTags = () =>
|
||||
tags.map((tag) => {
|
||||
const isTagged = taggedTags[tag.id];
|
||||
|
||||
const stashID = tag.stash_ids.find((s) => {
|
||||
return s.endpoint === selectedEndpoint.endpoint;
|
||||
});
|
||||
|
||||
let mainContent;
|
||||
if (!isTagged && stashID !== undefined) {
|
||||
mainContent = (
|
||||
<div className="text-left">
|
||||
<h5 className="text-bold">
|
||||
<FormattedMessage id="tag_tagger.tag_already_tagged" />
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
} else if (!isTagged && !stashID) {
|
||||
mainContent = (
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
defaultValue={tag.name ?? ""}
|
||||
onChange={(e) =>
|
||||
setQueries({
|
||||
...queries,
|
||||
[tag.id]: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
|
||||
e.key === "Enter" &&
|
||||
doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "")
|
||||
}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "")
|
||||
}
|
||||
>
|
||||
<FormattedMessage id="actions.search" />
|
||||
</Button>
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
);
|
||||
} else if (isTagged) {
|
||||
mainContent = (
|
||||
<div className="d-flex flex-column text-left">
|
||||
<h5>
|
||||
<FormattedMessage id="tag_tagger.tag_successfully_tagged" />
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let subContent;
|
||||
if (stashID !== undefined) {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? (
|
||||
<ExternalLink
|
||||
className="small d-block"
|
||||
href={`${base}tags/${stashID.stash_id}`}
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<div className="small">{stashID.stash_id}</div>
|
||||
);
|
||||
|
||||
subContent = (
|
||||
<div key={tag.id}>
|
||||
<InputGroup className="StudioTagger-box-link">
|
||||
<InputGroup.Text>{link}</InputGroup.Text>
|
||||
<InputGroup.Append>
|
||||
<Button
|
||||
onClick={() =>
|
||||
doBoxUpdate(tag.id, stashID.stash_id, stashID.endpoint)
|
||||
}
|
||||
disabled={!!loadingUpdate}
|
||||
>
|
||||
{loadingUpdate === stashID.stash_id ? (
|
||||
<LoadingIndicator inline small message="" />
|
||||
) : (
|
||||
<FormattedMessage id="actions.refresh" />
|
||||
)}
|
||||
</Button>
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
{error[tag.id] && (
|
||||
<div className="text-danger mt-1">
|
||||
<strong>
|
||||
<span className="mr-2">Error:</span>
|
||||
{error[tag.id]?.message}
|
||||
</strong>
|
||||
<div>{error[tag.id]?.details}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (searchErrors[tag.id]) {
|
||||
subContent = (
|
||||
<div className="text-danger font-weight-bold">
|
||||
{searchErrors[tag.id]}
|
||||
</div>
|
||||
);
|
||||
} else if (searchResults[tag.id]?.length === 0) {
|
||||
subContent = (
|
||||
<div className="text-danger font-weight-bold">
|
||||
<FormattedMessage id="tag_tagger.no_results_found" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let searchResult;
|
||||
if (searchResults[tag.id]?.length > 0 && !isTagged) {
|
||||
searchResult = (
|
||||
<StashSearchResult
|
||||
key={tag.id}
|
||||
stashboxTags={searchResults[tag.id]}
|
||||
tag={tag}
|
||||
endpoint={selectedEndpoint.endpoint}
|
||||
onTagTagged={handleTaggedTag}
|
||||
excludedTagFields={config.excludedTagFields ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={tag.id} className={`${CLASSNAME}-studio`}>
|
||||
<div className={`${CLASSNAME}-details`}>
|
||||
<div></div>
|
||||
<div>
|
||||
<Card className="studio-card">
|
||||
<img loading="lazy" src={tag.image_path ?? ""} alt="" />
|
||||
</Card>
|
||||
</div>
|
||||
<div className={`${CLASSNAME}-details-text`}>
|
||||
<Link to={`/tags/${tag.id}`} className={`${CLASSNAME}-header`}>
|
||||
<h2>{tag.name}</h2>
|
||||
</Link>
|
||||
{mainContent}
|
||||
<div className="sub-content text-left">{subContent}</div>
|
||||
{searchResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{showBatchUpdate && (
|
||||
<TagBatchUpdateModal
|
||||
close={() => setShowBatchUpdate(false)}
|
||||
isIdle={isIdle}
|
||||
selectedEndpoint={selectedEndpoint}
|
||||
tags={tags}
|
||||
onBatchUpdate={handleBatchUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBatchAdd && (
|
||||
<TagBatchAddModal
|
||||
close={() => setShowBatchAdd(false)}
|
||||
isIdle={isIdle}
|
||||
onBatchAdd={handleBatchAdd}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto mb-3">
|
||||
<Button onClick={() => setShowBatchAdd(true)}>
|
||||
<FormattedMessage id="tag_tagger.batch_add_tags" />
|
||||
</Button>
|
||||
<Button className="ml-3" onClick={() => setShowBatchUpdate(true)}>
|
||||
<FormattedMessage id="tag_tagger.batch_update_tags" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={CLASSNAME}>{renderTags()}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITaggerProps {
|
||||
tags: GQL.TagListDataFragment[];
|
||||
}
|
||||
|
||||
export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
||||
const jobsSubscribe = useJobsSubscribe();
|
||||
const intl = useIntl();
|
||||
const { configuration: stashConfig } = useConfigurationContext();
|
||||
const { config, setConfig } = useTaggerConfig();
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
|
||||
const [batchJobID, setBatchJobID] = useState<string | undefined | null>();
|
||||
const [batchJob, setBatchJob] = useState<JobFragment | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobsSubscribe.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = jobsSubscribe.data.jobsSubscribe;
|
||||
if (event.job.id !== batchJobID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type !== GQL.JobStatusUpdateType.Remove) {
|
||||
setBatchJob(event.job);
|
||||
} else {
|
||||
setBatchJob(undefined);
|
||||
setBatchJobID(undefined);
|
||||
|
||||
const ac = getClient();
|
||||
ac.cache.evict({ fieldName: "findTags" });
|
||||
ac.cache.gc();
|
||||
}
|
||||
}, [jobsSubscribe, batchJobID]);
|
||||
|
||||
if (!config) return <LoadingIndicator />;
|
||||
|
||||
const savedEndpointIndex =
|
||||
stashConfig?.general.stashBoxes.findIndex(
|
||||
(s) => s.endpoint === config.selectedEndpoint
|
||||
) ?? -1;
|
||||
const selectedEndpointIndex =
|
||||
savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length
|
||||
? 0
|
||||
: savedEndpointIndex;
|
||||
const selectedEndpoint =
|
||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
||||
|
||||
async function batchAdd(tagInput: string) {
|
||||
if (tagInput && selectedEndpoint) {
|
||||
const inputs = tagInput
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter((n) => n.length > 0);
|
||||
|
||||
const { names, stashIds } = separateNamesAndStashIds(inputs);
|
||||
|
||||
if (names.length > 0 || stashIds.length > 0) {
|
||||
const ret = await mutateStashBoxBatchTagTag({
|
||||
names: names.length > 0 ? names : undefined,
|
||||
stash_ids: stashIds.length > 0 ? stashIds : undefined,
|
||||
endpoint: selectedEndpointIndex,
|
||||
refresh: false,
|
||||
createParent: false,
|
||||
exclude_fields: config?.excludedTagFields ?? [],
|
||||
});
|
||||
|
||||
setBatchJobID(ret.data?.stashBoxBatchTagTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function batchUpdate(ids: string[] | undefined, refresh: boolean) {
|
||||
if (selectedEndpoint) {
|
||||
const ret = await mutateStashBoxBatchTagTag({
|
||||
ids: ids,
|
||||
endpoint: selectedEndpointIndex,
|
||||
refresh,
|
||||
createParent: false,
|
||||
exclude_fields: config?.excludedTagFields ?? [],
|
||||
});
|
||||
|
||||
setBatchJobID(ret.data?.stashBoxBatchTagTag);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
if (batchJob) {
|
||||
const progress =
|
||||
batchJob.progress !== undefined && batchJob.progress !== null
|
||||
? batchJob.progress * 100
|
||||
: undefined;
|
||||
return (
|
||||
<Form.Group className="px-4">
|
||||
<h5>
|
||||
<FormattedMessage id="tag_tagger.status_tagging_tags" />
|
||||
</h5>
|
||||
{progress !== undefined && (
|
||||
<ProgressBar
|
||||
animated
|
||||
now={progress}
|
||||
label={`${progress.toFixed(0)}%`}
|
||||
/>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (batchJobID !== undefined) {
|
||||
return (
|
||||
<Form.Group className="px-4">
|
||||
<h5>
|
||||
<FormattedMessage id="tag_tagger.status_tagging_job_queued" />
|
||||
</h5>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const showHideConfigId = showConfig
|
||||
? "actions.hide_configuration"
|
||||
: "actions.show_configuration";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Manual
|
||||
show={showManual}
|
||||
onClose={() => setShowManual(false)}
|
||||
defaultActiveTab="Tagger.md"
|
||||
/>
|
||||
{renderStatus()}
|
||||
<div className="tagger-container mx-md-auto">
|
||||
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
|
||||
<>
|
||||
<div className="row mb-2 no-gutters">
|
||||
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
|
||||
{intl.formatMessage({ id: showHideConfigId })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() => setShowManual(true)}
|
||||
title={intl.formatMessage({ id: "help" })}
|
||||
variant="link"
|
||||
>
|
||||
<FormattedMessage id="help" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TaggerConfig
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
show={showConfig}
|
||||
excludedFields={config.excludedTagFields ?? []}
|
||||
onFieldsChange={(fields) =>
|
||||
setConfig({ ...config, excludedTagFields: fields })
|
||||
}
|
||||
fields={TAG_FIELDS}
|
||||
entityName="tags"
|
||||
/>
|
||||
<TagTaggerList
|
||||
tags={tags}
|
||||
selectedEndpoint={{
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
index: selectedEndpointIndex,
|
||||
}}
|
||||
isIdle={batchJobID === undefined}
|
||||
config={config}
|
||||
onBatchAdd={batchAdd}
|
||||
onBatchUpdate={batchUpdate}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="my-4">
|
||||
<h3 className="text-center mt-4">
|
||||
<FormattedMessage id="tag_tagger.to_use_the_tag_tagger" />
|
||||
</h3>
|
||||
<h5 className="text-center">
|
||||
Please see{" "}
|
||||
<HashLink
|
||||
to="/settings?tab=metadata-providers#stash-boxes"
|
||||
scroll={(el) =>
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
>
|
||||
Settings.
|
||||
</HashLink>
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ParseMode } from "./constants";
|
||||
import { queryFindStudio } from "src/core/StashService";
|
||||
import { queryFindStudio, queryFindTag } from "src/core/StashService";
|
||||
import { mergeStashIDs } from "src/utils/stashbox";
|
||||
|
||||
const months = [
|
||||
|
|
@ -173,14 +173,32 @@ export const parsePath = (filePath: string) => {
|
|||
return { paths, file, ext };
|
||||
};
|
||||
|
||||
export async function mergeStudioStashIDs(
|
||||
async function mergeEntityStashIDs(
|
||||
fetchExisting: (id: string) => Promise<GQL.StashIdInput[] | undefined>,
|
||||
id: string,
|
||||
newStashIDs: GQL.StashIdInput[]
|
||||
) {
|
||||
const existing = await queryFindStudio(id);
|
||||
if (existing?.data?.findStudio?.stash_ids) {
|
||||
return mergeStashIDs(existing.data.findStudio.stash_ids, newStashIDs);
|
||||
const existing = await fetchExisting(id);
|
||||
if (existing) {
|
||||
return mergeStashIDs(existing, newStashIDs);
|
||||
}
|
||||
|
||||
return newStashIDs;
|
||||
}
|
||||
|
||||
export const mergeStudioStashIDs = (
|
||||
id: string,
|
||||
newStashIDs: GQL.StashIdInput[]
|
||||
) =>
|
||||
mergeEntityStashIDs(
|
||||
async (studioId) =>
|
||||
(await queryFindStudio(studioId))?.data?.findStudio?.stash_ids,
|
||||
id,
|
||||
newStashIDs
|
||||
);
|
||||
|
||||
export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) =>
|
||||
mergeEntityStashIDs(
|
||||
async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids,
|
||||
id,
|
||||
newStashIDs
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { EditTagsDialog } from "./EditTagsDialog";
|
|||
import { View } from "../List/views";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { TagTagger } from "../Tagger/tags/TagTagger";
|
||||
|
||||
function getItems(result: GQL.FindTagsForListQueryResult) {
|
||||
return result?.data?.findTags?.tags ?? [];
|
||||
|
|
@ -355,6 +356,9 @@ export const TagList: React.FC<ITagList> = PatchComponent(
|
|||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <TagTagger tags={result.data.findTags.tags} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2463,6 +2463,12 @@ export const mutateStashBoxBatchStudioTag = (
|
|||
variables: { input },
|
||||
});
|
||||
|
||||
export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) =>
|
||||
client.mutate<GQL.StashBoxBatchTagTagMutation>({
|
||||
mutation: GQL.StashBoxBatchTagTagDocument,
|
||||
variables: { input },
|
||||
});
|
||||
|
||||
export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery();
|
||||
|
||||
export const queryScrapeGroupURL = (url: string) =>
|
||||
|
|
|
|||
|
|
@ -1345,14 +1345,6 @@
|
|||
"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_performers": "Batch Add Performers",
|
||||
"batch_update_performers": "Batch Update Performers",
|
||||
"config": {
|
||||
"active_stash-box_instance": "Active stash-box instance:",
|
||||
"edit_excluded_fields": "Edit Excluded Fields",
|
||||
"excluded_fields": "Excluded fields:",
|
||||
"no_fields_are_excluded": "No fields are excluded",
|
||||
"no_instances_found": "No instances found",
|
||||
"these_fields_will_not_be_changed_when_updating_performers": "These fields will not be changed when updating performers."
|
||||
},
|
||||
"current_page": "Current page",
|
||||
"failed_to_save_performer": "Failed to save performer \"{performer}\"",
|
||||
"name_already_exists": "Name already exists",
|
||||
|
|
@ -1555,14 +1547,8 @@
|
|||
"batch_add_studios": "Batch Add Studios",
|
||||
"batch_update_studios": "Batch Update Studios",
|
||||
"config": {
|
||||
"active_stash-box_instance": "Active stash-box instance:",
|
||||
"create_parent_desc": "Create missing parent studios, or tag and update data/image for existing parent studios with exact name matches",
|
||||
"create_parent_label": "Create parent studios",
|
||||
"edit_excluded_fields": "Edit Excluded Fields",
|
||||
"excluded_fields": "Excluded fields:",
|
||||
"no_fields_are_excluded": "No fields are excluded",
|
||||
"no_instances_found": "No instances found",
|
||||
"these_fields_will_not_be_changed_when_updating_studios": "These fields will not be changed when updating studios."
|
||||
"create_parent_label": "Create parent studios"
|
||||
},
|
||||
"create_or_tag_parent_studios": "Create missing or tag existing parent studios",
|
||||
"current_page": "Current page",
|
||||
|
|
@ -1604,6 +1590,42 @@
|
|||
"tag_count": "Tag Count",
|
||||
"tag_parent_tooltip": "Has parent tags",
|
||||
"tag_sub_tag_tooltip": "Has sub-tags",
|
||||
"tag_tagger": {
|
||||
"add_new_tags": "Add New Tags",
|
||||
"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",
|
||||
"current_page": "Current page",
|
||||
"failed_to_save_tag": "Failed to save tag \"{tag}\"",
|
||||
"name_already_exists": "Name already exists",
|
||||
"network_error": "Network Error",
|
||||
"no_results_found": "No results found.",
|
||||
"number_of_tags_will_be_processed": "{tag_count} tags will be processed",
|
||||
"query_all_tags_in_the_database": "All tags in the database",
|
||||
"refresh_tagged_tags": "Refresh tagged tags",
|
||||
"refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.",
|
||||
"status_tagging_job_queued": "Status: Tagging job queued",
|
||||
"status_tagging_tags": "Status: Tagging tags",
|
||||
"tag_already_tagged": "Tag already tagged",
|
||||
"tag_names_or_stashids_separated_by_comma": "Tag names or StashIDs separated by comma",
|
||||
"tag_selection": "Tag selection",
|
||||
"tag_successfully_tagged": "Tag successfully tagged",
|
||||
"tag_status": "Tag Status",
|
||||
"to_use_the_tag_tagger": "To use the tag tagger a stash-box instance needs to be configured.",
|
||||
"untagged_tags": "Untagged tags",
|
||||
"update_tags": "Update Tags",
|
||||
"updating_untagged_tags_description": "Updating untagged tags will try to match any tags that lack a stashid and update the metadata."
|
||||
},
|
||||
"tagger": {
|
||||
"config": {
|
||||
"active_stash-box_instance": "Active stash-box instance:",
|
||||
"edit_excluded_fields": "Edit Excluded Fields",
|
||||
"excluded_fields": "Excluded fields:",
|
||||
"fields_will_not_be_changed": "These fields will not be changed when updating {entity}.",
|
||||
"no_fields_are_excluded": "No fields are excluded",
|
||||
"no_instances_found": "No instances found"
|
||||
}
|
||||
},
|
||||
"tags": "Tags",
|
||||
"tattoos": "Tattoos",
|
||||
"time": "Time",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ const sortByOptions = ["name", "random", "scenes_duration"]
|
|||
},
|
||||
]);
|
||||
|
||||
const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
|
||||
const displayModeOptions = [
|
||||
DisplayMode.Grid,
|
||||
DisplayMode.List,
|
||||
DisplayMode.Tagger,
|
||||
];
|
||||
const criterionOptions = [
|
||||
FavoriteTagCriterionOption,
|
||||
createMandatoryStringCriterionOption("name"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue