diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 996afefe7..7f07e4579 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 9c0e33fdf..b8810aa79 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -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 } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index e2686ac4d..edd44c835 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -29,6 +29,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment MeasurementsFragment on Measurements { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 436937511..6d2ab84fd 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -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 { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index bac726c1b..e97227fcf 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -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) +} diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 4848b46ad..97c766010 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -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) + } + } +} diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 95a3b7a87..c4423ee52 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -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) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 3c0e083c1..1367003cb 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -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)) } diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index ba403cf2d..02dfe0cb6 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -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. diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a926dd56e..750836516 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -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) } diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 29b702a7f..acb2202dc 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -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 } ` diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index df2ecbcc0..452dd9928 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -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 +} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index e58c21a20..7214c2064 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -160,6 +160,8 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + description + alias_list remote_site_id } diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index 596dc4302..de5f5136c 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -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) } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index f383f245a..6bd535df7 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -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} diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/FieldSelector.tsx similarity index 84% rename from ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx rename to ui/v2.5/src/components/Tagger/FieldSelector.tsx index b50716511..7a47862b5 100644 --- a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx +++ b/ui/v2.5/src/components/Tagger/FieldSelector.tsx @@ -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 = ({ +const FieldSelector: React.FC = ({ show, + fields, excludedFields, onSelect, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( - 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 = ({
These fields will be tagged by default. Click the button to toggle.
- {PERFORMER_FIELDS.map((f) => renderField(f))} + {fields.map((f) => renderField(f))} ); }; -export default PerformerFieldSelect; +export default FieldSelector; diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx similarity index 69% rename from ui/v2.5/src/components/Tagger/performers/Config.tsx rename to ui/v2.5/src/components/Tagger/TaggerConfig.tsx index 0d5316735..c578d58c4 100644 --- a/ui/v2.5/src/components/Tagger/performers/Config.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx @@ -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; + excludedFields: string[]; + onFieldsChange: (fields: string[]) => void; + fields: string[]; + entityName: string; + extraConfig?: React.ReactNode; } -const Config: React.FC = ({ show, config, setConfig }) => { +const TaggerConfig: React.FC = ({ + 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) => { const selectedEndpoint = e.currentTarget.value; setConfig({ @@ -28,8 +40,8 @@ const Config: React.FC = ({ 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 = ({ show, config, setConfig }) => {
- + {extraConfig} +
- +
{excludedFields.length > 0 ? ( @@ -55,17 +68,20 @@ const Config: React.FC = ({ show, config, setConfig }) => { )) ) : ( - + )} - +
= ({ show, config, setConfig }) => { className="align-items-center row no-gutters mt-4" > - + = ({ show, config, setConfig }) => { > {!stashBoxes.length && ( )} {stashConfig?.general.stashBoxes.map((i) => ( @@ -98,8 +114,9 @@ const Config: React.FC = ({ show, config, setConfig }) => {
- @@ -107,4 +124,4 @@ const Config: React.FC = ({ show, config, setConfig }) => { ); }; -export default Config; +export default TaggerConfig; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d59a6d3d5..af9afcefb 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -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"]; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index bb934a241..8106d6a44 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -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 = ({ performers }) => { - + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" /> { 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({ + 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; +}; diff --git a/ui/v2.5/src/components/Tagger/studios/Config.tsx b/ui/v2.5/src/components/Tagger/studios/Config.tsx deleted file mode 100644 index ddfd17b1e..000000000 --- a/ui/v2.5/src/components/Tagger/studios/Config.tsx +++ /dev/null @@ -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; -} - -const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = useConfigurationContext(); - const [showExclusionModal, setShowExclusionModal] = useState(false); - - const excludedFields = config.excludedStudioFields ?? []; - - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedStudioFields: fields }); - setShowExclusionModal(false); - }; - - return ( - <> - - -
-

- -

-
-
- - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - -
- -
- - {excludedFields.length > 0 ? ( - excludedFields.map((f) => ( - - - - )) - ) : ( - - )} - - - - - -
- - - - - - {!stashBoxes.length && ( - - )} - {stashConfig?.general.stashBoxes.map((i) => ( - - ))} - - -
-
-
-
- - - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx b/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx deleted file mode 100644 index 658f23510..000000000 --- a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx +++ /dev/null @@ -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 = ({ - show, - excludedFields, - onSelect, -}) => { - const intl = useIntl(); - const [excluded, setExcluded] = useState>( - // 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) => ( - - - {intl.formatMessage({ id: field })} - - ); - - return ( - - onSelect(Object.keys(excluded).filter((f) => excluded[f])), - }} - > -

Select tagged fields

-
- These fields will be tagged by default. Click the button to toggle. -
- {STUDIO_FIELDS.map((f) => renderField(f))} -
- ); -}; - -export default StudioFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index ed9570431..64bb99b72 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -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 = ({ studios }) => { - + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } /> & + Partial> + ) => void; + excludedTagFields: string[]; +} + +const StashSearchResult: React.FC = ({ + tag, + stashboxTags, + onTagTagged, + excludedTagFields, + endpoint, +}) => { + const intl = useIntl(); + + const [modalTag, setModalTag] = useState(); + const [saveState, setSaveState] = useState(""); + 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) => ( + + )); + + return ( + <> + {modalTag && ( + setModalTag(undefined)} + modalVisible={modalTag !== undefined} + tag={modalTag} + onSave={handleSave} + icon={faTags} + header="Update Tag" + excludedTagFields={excludedTagFields} + endpoint={endpoint} + /> + )} +
{tags}
+
+ {error.message && ( +
+ + Error: + {error.message} + +
{error.details}
+
+ )} + {saveState && ( + {saveState} + )} +
+ + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx new file mode 100644 index 000000000..1183d8f0c --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -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 = ({ + modalVisible, + tag, + onSave, + closeModal, + excludedTagFields = [], + header, + icon, + endpoint, +}) => { + const intl = useIntl(); + + const [excluded, setExcluded] = useState>( + 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 ( +
+
+ + + : + +
+ +
+ ); + } + + function maybeRenderStashBoxLink() { + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; + + if (!link) return; + + return ( +
+ + + + +
+ ); + } + + 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 ( + closeModal(), variant: "secondary" }} + onHide={() => closeModal()} + dialogClassName="studio-create-modal" + icon={icon} + header={header} + > +
+
+
+ {maybeRenderField("name", tag.name)} + {maybeRenderField("description", tag.description)} + {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderStashBoxLink()} +
+
+
+
+ ); +}; + +export default TagModal; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx new file mode 100644 index 000000000..1113bdfd4 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -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 = ({ + 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 ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
+ +
+
+ } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
+ + +
+ +
+
+ setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
+ + + +
+ ); +}; + +interface ITagBatchAddModal { + isIdle: boolean; + onBatchAdd: (input: string) => void; + close: () => void; +} + +const TagBatchAddModal: React.FC = ({ + isIdle, + onBatchAdd, + close, +}) => { + const intl = useIntl(); + + const tagInput = useRef(null); + + return ( + { + if (tagInput.current) { + onBatchAdd(tagInput.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + + + ); +}; + +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 = ({ + tags, + selectedEndpoint, + isIdle, + config, + onBatchAdd, + onBatchUpdate, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedTags, setTaggedTags] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + + 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 & + Partial> + ) => { + 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 = ( +
+
+ +
+
+ ); + } else if (!isTagged && !stashID) { + mainContent = ( + + + setQueries({ + ...queries, + [tag.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "") + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
+
+ +
+
+ ); + } + + let subContent; + if (stashID !== undefined) { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + subContent = ( +
+ + {link} + + + + + {error[tag.id] && ( +
+ + Error: + {error[tag.id]?.message} + +
{error[tag.id]?.details}
+
+ )} +
+ ); + } else if (searchErrors[tag.id]) { + subContent = ( +
+ {searchErrors[tag.id]} +
+ ); + } else if (searchResults[tag.id]?.length === 0) { + subContent = ( +
+ +
+ ); + } + + let searchResult; + if (searchResults[tag.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
+
+
+
+ + + +
+
+ +

{tag.name}

+ + {mainContent} +
{subContent}
+ {searchResult} +
+
+
+ ); + }); + + return ( + + {showBatchUpdate && ( + setShowBatchUpdate(false)} + isIdle={isIdle} + selectedEndpoint={selectedEndpoint} + tags={tags} + onBatchUpdate={handleBatchUpdate} + /> + )} + + {showBatchAdd && ( + setShowBatchAdd(false)} + isIdle={isIdle} + onBatchAdd={handleBatchAdd} + /> + )} +
+ + +
+
{renderTags()}
+
+ ); +}; + +interface ITaggerProps { + tags: GQL.TagListDataFragment[]; +} + +export const TagTagger: React.FC = ({ 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(); + const [batchJob, setBatchJob] = useState(); + + 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 ; + + 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 ( + +
+ +
+ {progress !== undefined && ( + + )} +
+ ); + } + + if (batchJobID !== undefined) { + return ( + +
+ +
+
+ ); + } + } + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + <> + setShowManual(false)} + defaultActiveTab="Tagger.md" + /> + {renderStatus()} +
+ {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
+ + +
+ + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + /> + + + ) : ( +
+

+ +

+
+ Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
+
+ )} +
+ + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8c1cf54e5..cddad33d9 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -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, 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 + ); diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index e30f6071b..61b81b727 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -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 = PatchComponent( if (filter.displayMode === DisplayMode.Wall) { return

TODO

; } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } } return ( <> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d276806fc..27186d6e1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2463,6 +2463,12 @@ export const mutateStashBoxBatchStudioTag = ( variables: { input }, }); +export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) => + client.mutate({ + mutation: GQL.StashBoxBatchTagTagDocument, + variables: { input }, + }); + export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery(); export const queryScrapeGroupURL = (url: string) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 629f1ece8..b7d3e2894 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index e2d4fbed4..39ce9ca39 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -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"),