From 39fd8a65502fc63bc6807bbb05673422619f58aa Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:20:29 -0600 Subject: [PATCH] Feature: Manual StashId Search - Tags (#6374) --- graphql/schema/schema.graphql | 6 + graphql/schema/types/scraper.graphql | 7 + graphql/stash-box/query.graphql | 15 ++ internal/api/resolver_query_scraper.go | 39 +++++ pkg/stashbox/graphql/generated_client.go | 62 ++++++++ pkg/stashbox/tag.go | 67 ++++++++ .../graphql/queries/scrapers/scrapers.graphql | 9 ++ .../Shared/StashBoxIDSearchModal.tsx | 43 +++++- .../Tags/TagDetails/TagEditPanel.tsx | 144 ++++++++++++------ ui/v2.5/src/core/StashService.ts | 17 +++ ui/v2.5/src/locales/en-GB.json | 1 + 11 files changed, 360 insertions(+), 50 deletions(-) create mode 100644 pkg/stashbox/tag.go diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 2a9d067ae..8936b8a34 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -165,6 +165,12 @@ type Query { input: ScrapeSingleStudioInput! ): [ScrapedStudio!]! + "Scrape for a single tag" + scrapeSingleTag( + source: ScraperSourceInput! + input: ScrapeSingleTagInput! + ): [ScrapedTag!]! + "Scrape for a single performer" scrapeSinglePerformer( source: ScraperSourceInput! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index cb193f47d..9c0e33fdf 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -198,6 +198,13 @@ input ScrapeSingleStudioInput { query: String } +input ScrapeSingleTagInput { + """ + Query can be either a name or a Stash ID + """ + query: String +} + input ScrapeSinglePerformerInput { "Instructs to query by string" query: String diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 4fa023070..2367e85cf 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) { } } +query FindTag($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ...TagFragment + } +} + +query QueryTags($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ...TagFragment + } + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 4d77f227d..86d449921 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -353,6 +353,45 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S return nil, errors.New("stash_box_endpoint must be set") } +func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) { + if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) + if err != nil { + return nil, err + } + + client := r.newStashBoxClient(*b) + + var ret []*models.ScrapedTag + out, err := client.QueryTag(ctx, *input.Query) + + if err != nil { + return nil, err + } else if out != nil { + ret = append(ret, out...) + } + + if len(ret) > 0 { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + for _, tag := range ret { + if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + return ret, nil + } + + return nil, nil + } + + return nil, errors.New("stash_box_endpoint must be set") +} + func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { var ret []*models.ScrapedPerformer switch { diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index e2a18352e..640a1c893 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -18,6 +18,7 @@ type StashBoxGraphQLClient interface { FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) + QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) @@ -643,6 +644,24 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type QueryTags_QueryTags struct { + Count int "json:\"count\" graphql:\"count\"" + Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" +} + +func (t *QueryTags_QueryTags) GetCount() int { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Count +} +func (t *QueryTags_QueryTags) GetTags() []*TagFragment { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Tags +} + type Me_Me struct { Name string "json:\"name\" graphql:\"name\"" } @@ -775,6 +794,17 @@ func (t *FindTag) GetFindTag() *TagFragment { return t.FindTag } +type QueryTags struct { + QueryTags QueryTags_QueryTags "json:\"queryTags\" graphql:\"queryTags\"" +} + +func (t *QueryTags) GetQueryTags() *QueryTags_QueryTags { + if t == nil { + t = &QueryTags{} + } + return &t.QueryTags +} + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -1736,6 +1766,37 @@ func (c *Client) FindTag(ctx context.Context, id *string, name *string, intercep return &res, nil } +const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ... TagFragment + } + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) { + vars := map[string]any{ + "input": input, + } + + var res QueryTags + if err := c.Client.Post(ctx, "QueryTags", QueryTagsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } @@ -1838,6 +1899,7 @@ var DocumentOperationNames = map[string]string{ FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", FindTagDocument: "FindTag", + QueryTagsDocument: "QueryTags", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go new file mode 100644 index 000000000..df2ecbcc0 --- /dev/null +++ b/pkg/stashbox/tag.go @@ -0,0 +1,67 @@ +package stashbox + +import ( + "context" + + "github.com/google/uuid" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/stashbox/graphql" +) + +// QueryTag searches for tags by name or ID. +// If query is a valid UUID, it searches by ID (returns single result). +// Otherwise, it searches by name (returns multiple results). +func (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) { + _, err := uuid.Parse(query) + if err == nil { + // Query is a UUID, use findTag for exact match + return c.findTagByID(ctx, query) + } + // Otherwise search by name + return c.queryTagsByName(ctx, query) +} + +func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) { + tag, err := c.client.FindTag(ctx, &id, nil) + if err != nil { + return nil, err + } + + if tag.FindTag == nil { + return nil, nil + } + + return []*models.ScrapedTag{{ + Name: tag.FindTag.Name, + RemoteSiteID: &tag.FindTag.ID, + }}, nil +} + +func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { + input := graphql.TagQueryInput{ + Name: &name, + Page: 1, + PerPage: 25, + Direction: graphql.SortDirectionEnumAsc, + Sort: graphql.TagSortEnumName, + } + + result, err := c.client.QueryTags(ctx, input) + if err != nil { + return nil, err + } + + if result.QueryTags.Tags == nil { + return nil, nil + } + + var ret []*models.ScrapedTag + for _, t := range result.QueryTags.Tags { + ret = append(ret, &models.ScrapedTag{ + Name: t.Name, + RemoteSiteID: &t.ID, + }) + } + + return ret, nil +} diff --git a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql index 8137fe054..4ddfbd91b 100644 --- a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql +++ b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql @@ -62,6 +62,15 @@ query ScrapeSingleStudio( } } +query ScrapeSingleTag( + $source: ScraperSourceInput! + $input: ScrapeSingleTagInput! +) { + scrapeSingleTag(source: $source, input: $input) { + ...ScrapedSceneTagData + } +} + query ScrapeSinglePerformer( $source: ScraperSourceInput! $input: ScrapeSinglePerformerInput! diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 790e6aed9..4674db08a 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -15,6 +15,7 @@ import { stashBoxPerformerQuery, stashBoxSceneQuery, stashBoxStudioQuery, + stashBoxTagQuery, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { stringToGender } from "src/utils/gender"; @@ -22,9 +23,10 @@ import { stringToGender } from "src/utils/gender"; type SearchResultItem = | GQL.ScrapedPerformerDataFragment | GQL.ScrapedSceneDataFragment - | GQL.ScrapedStudioDataFragment; + | GQL.ScrapedStudioDataFragment + | GQL.ScrapedSceneTagDataFragment; -export type StashBoxEntityType = "performer" | "scene" | "studio"; +export type StashBoxEntityType = "performer" | "scene" | "studio" | "tag"; interface IProps { entityType: StashBoxEntityType; @@ -232,6 +234,27 @@ export const StudioSearchResult: React.FC = ({ ); }; +// Tag Result Component +interface ITagResultProps { + tag: GQL.ScrapedSceneTagDataFragment; +} + +export const TagSearchResult: React.FC = ({ tag }) => { + return ( +
+
+ +
+

+ {tag.name} +

+
+
+
+
+ ); +}; + // Helper to get entity type message id for i18n function getEntityTypeMessageId(entityType: StashBoxEntityType): string { switch (entityType) { @@ -241,6 +264,8 @@ function getEntityTypeMessageId(entityType: StashBoxEntityType): string { return "scene"; case "studio": return "studio"; + case "tag": + return "tag"; } } @@ -253,6 +278,8 @@ function getFoundMessageId(entityType: StashBoxEntityType): string { return "dialogs.scenes_found"; case "studio": return "dialogs.studios_found"; + case "tag": + return "dialogs.tags_found"; } } @@ -318,6 +345,14 @@ export const StashBoxIDSearchModal: React.FC = ({ setResults(queryData.data?.scrapeSingleStudio ?? []); break; } + case "tag": { + const queryData = await stashBoxTagQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleTag ?? []); + break; + } } } catch (error) { Toast.error(error); @@ -357,6 +392,10 @@ export const StashBoxIDSearchModal: React.FC = ({ return ( ); + case "tag": + return ( + + ); } } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 41756953b..35733394a 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -3,7 +3,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; @@ -11,11 +12,14 @@ import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface ITagEditPanel { tag: Partial; @@ -36,9 +40,13 @@ export const TagEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = tag.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -143,6 +151,14 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const { renderField, renderInputField, @@ -186,54 +202,86 @@ export const TagEditPanel: React.FC = ({ // TODO: CSS class return ( -
- {isNew && ( -

- -

+ <> + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> )} - { - // Check if it's a redirect after tag creation - if (action === "PUSH" && location.pathname.startsWith("/tags/")) { - return true; +
+ {isNew && ( +

+ +

+ )} + + { + // Check if it's a redirect after tag creation + if (action === "PUSH" && location.pathname.startsWith("/tags/")) { + return true; + } + + return handleUnsavedChanges(intl, "tags", tag.id)(location); + }} + /> + +
+ {renderInputField("name")} + {renderInputField("sort_name", "text")} + {renderStringListField("aliases")} + {renderInputField("description", "textarea")} + {renderParentTagsField()} + {renderSubTagsField()} + {renderStashIDsField( + "stash_ids", + "tags", + "stash_ids", + undefined, + + )} +
+ {renderInputField("ignore_auto_tag", "checkbox")} +
+ + - -
- {renderInputField("name")} - {renderInputField("sort_name", "text")} - {renderStringListField("aliases")} - {renderInputField("description", "textarea")} - {renderParentTagsField()} - {renderSubTagsField()} - {renderStashIDsField("stash_ids", "tags")} -
- {renderInputField("ignore_auto_tag", "checkbox")} -
- - onImageLoad(null)} - onDelete={onDelete} - acceptSVG - /> -
+ onImageChange={onImageChange} + onImageChangeURL={onImageLoad} + onClearImage={() => onImageLoad(null)} + onDelete={onDelete} + acceptSVG + /> +
+ ); }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index e69d988bf..d43d87097 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2329,6 +2329,23 @@ export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) => } ); +export const stashBoxTagQuery = ( + query: string | null, + stashBoxEndpoint: string +) => + client.query({ + query: GQL.ScrapeSingleTagDocument, + variables: { + source: { + stash_box_endpoint: stashBoxEndpoint, + }, + input: { + query: query, + }, + }, + fetchPolicy: "network-only", + }); + export const mutateStashBoxBatchPerformerTag = ( input: GQL.StashBoxBatchTagInput ) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index c2b9b4247..54982b932 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1016,6 +1016,7 @@ }, "scenes_found": "{count} scenes found", "studios_found": "{count} studios found", + "tags_found": "{count} tags found", "scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing",