This commit is contained in:
WithoutPants 2026-02-06 08:41:46 +02:00 committed by GitHub
commit dbaafe2484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 560 additions and 123 deletions

View file

@ -78,6 +78,8 @@ type FindTagsResultType {
input TagsMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: TagUpdateInput
}
input BulkTagUpdateInput {

View file

@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
@ -103,17 +102,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
return r.getTag(ctx, newTag.ID)
}
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
tagID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate tag from the input
func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {
updatedTag := models.NewTagPartial()
updatedTag.Name = translator.optionalString(input.Name, "name")
@ -132,6 +121,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
}
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
var err error
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err)
@ -149,6 +139,25 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)
}
return &updatedTag, nil
}
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
tagID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate tag from the input
updatedTag, err := tagPartialFromInput(input, translator)
if err != nil {
return nil, err
}
var imageData []byte
imageIncluded := translator.hasField("image")
if input.Image != nil {
@ -185,11 +194,11 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
}
}
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {
return err
}
t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
t, err = qb.UpdatePartial(ctx, tagID, *updatedTag)
if err != nil {
return err
}
@ -337,6 +346,31 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return nil, nil
}
var values *models.TagPartial
var imageData []byte
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, err = tagPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewTagPartial()
values = &v
}
var t *models.Tag
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag
@ -351,28 +385,22 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return fmt.Errorf("tag with id %d not found", destination)
}
parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb)
if err != nil {
return err
}
if err = qb.Merge(ctx, source, destination); err != nil {
return err
}
err = qb.UpdateParentTags(ctx, destination, parents)
if err != nil {
return err
}
err = qb.UpdateChildTags(ctx, destination, children)
if err != nil {
if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {
return err
}
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
if err != nil {
logger.Errorf("Error merging tag: %s", err)
return err
if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {
return fmt.Errorf("updating tag: %w", err)
}
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destination, imageData); err != nil {
return err
}
}
return nil

View file

@ -220,49 +220,3 @@ func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs,
return nil
}
func MergeHierarchy(ctx context.Context, destination int, sources []int, qb RelationshipFinder) ([]int, []int, error) {
var mergedParents, mergedChildren []int
allIds := append([]int{destination}, sources...)
addTo := func(mergedItems []int, tagIDs []int) []int {
Tags:
for _, tagID := range tagIDs {
// Ignore tags which are already set
for _, existingItem := range mergedItems {
if tagID == existingItem {
continue Tags
}
}
// Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored)
for _, id := range allIds {
if tagID == id {
continue Tags
}
}
mergedItems = append(mergedItems, tagID)
}
return mergedItems
}
for _, id := range allIds {
parents, err := qb.GetParentIDs(ctx, id)
if err != nil {
return nil, nil, err
}
mergedParents = addTo(mergedParents, parents)
children, err := qb.GetChildIDs(ctx, id)
if err != nil {
return nil, nil, err
}
mergedChildren = addTo(mergedChildren, children)
}
return mergedParents, mergedChildren, nil
}

View file

@ -34,6 +34,8 @@ fragment TagData on Tag {
children {
...SlimTagData
}
custom_fields
}
fragment SelectTagData on Tag {

View file

@ -24,8 +24,14 @@ mutation BulkTagUpdate($input: BulkTagUpdateInput!) {
}
}
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
tagsMerge(input: { source: $source, destination: $destination }) {
mutation TagsMerge(
$source: [ID!]!
$destination: ID!
$values: TagUpdateInput
) {
tagsMerge(
input: { source: $source, destination: $destination, values: $values }
) {
...TagData
}
}

View file

@ -1,5 +1,9 @@
query FindTags($filter: FindFilterType, $tag_filter: TagFilterType) {
findTags(filter: $filter, tag_filter: $tag_filter) {
query FindTags(
$filter: FindFilterType
$tag_filter: TagFilterType
$ids: [ID!]
) {
findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) {
count
tags {
...TagData

View file

@ -19,6 +19,7 @@ import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import {
ScrapedCustomFieldRows,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
@ -27,9 +28,9 @@ import {
import { ModalComponent } from "../Shared/Modal";
import { sortStoredIdObjects } from "src/utils/data";
import {
CustomFieldScrapeResults,
ObjectListScrapeResult,
ScrapeResult,
ZeroableScrapeResult,
hasScrapedValues,
} from "../Shared/ScrapeDialog/scrapeResult";
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
@ -40,39 +41,6 @@ import {
import { PerformerSelect } from "./PerformerSelect";
import { uniq } from "lodash-es";
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support
// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same
// for consistency.
function renderScrapedCustomFieldRows(
results: CustomFieldScrapeResults,
onChange: (newCustomFields: CustomFieldScrapeResults) => void
) {
return (
<>
{Array.from(results.entries()).map(([field, result]) => {
const fieldName = `custom_${field}`;
return (
<ScrapedInputGroupRow
className="custom-field"
title={field}
field={fieldName}
key={fieldName}
result={result}
onChange={(newResult) => {
const newResults = new Map(results);
newResults.set(field, newResult);
onChange(newResults);
}}
/>
);
})}
</>
);
}
type MergeOptions = {
values: GQL.PerformerUpdateInput;
};
@ -604,10 +572,12 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
result={image}
onChange={(value) => setImage(value)}
/>
{hasCustomFieldValues &&
renderScrapedCustomFieldRows(customFields, (newCustomFields) =>
setCustomFields(newCustomFields)
)}
{hasCustomFieldValues && (
<ScrapedCustomFieldRows
results={customFields}
onChange={(newCustomFields) => setCustomFields(newCustomFields)}
/>
)}
</>
);
}

View file

@ -14,7 +14,7 @@ import { getCountryByISO } from "src/utils/country";
import { CountrySelect } from "../CountrySelect";
import { StringListInput } from "../StringListInput";
import { ImageSelector } from "../ImageSelector";
import { ScrapeResult } from "./scrapeResult";
import { CustomFieldScrapeResults, ScrapeResult } from "./scrapeResult";
import { ScrapeDialogContext } from "./ScrapeDialog";
function renderButtonIcon(selected: boolean) {
@ -431,3 +431,30 @@ export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
onChange={onChange}
/>
);
export const ScrapedCustomFieldRows: React.FC<{
results: CustomFieldScrapeResults;
onChange: (newCustomFields: CustomFieldScrapeResults) => void;
}> = ({ results, onChange }) => {
return (
<>
{Array.from(results.entries()).map(([field, result]) => {
const fieldName = `custom_${field}`;
return (
<ScrapedInputGroupRow
className="custom-field"
title={field}
field={fieldName}
key={fieldName}
result={result}
onChange={(newResult) => {
const newResults = new Map(results);
newResults.set(field, newResult);
onChange(newResults);
}}
/>
);
})}
</>
);
};

View file

@ -2,6 +2,9 @@ import lodashIsEqual from "lodash-es/isEqual";
import clone from "lodash-es/clone";
import { IHasStoredID } from "src/utils/data";
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
export class ScrapeResult<T> {
public newValue?: T;
public originalValue?: T;

View file

@ -1,13 +1,412 @@
import { Button, Form, Col, Row } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "../Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal";
import * as FormUtils from "src/utils/form";
import { useTagsMerge } from "src/core/StashService";
import { useIntl } from "react-intl";
import { queryFindTagsByID, useTagsMerge } from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { Tag, TagSelect } from "./TagSelect";
import {
CustomFieldScrapeResults,
hasScrapedValues,
ObjectListScrapeResult,
ScrapeResult,
} from "../Shared/ScrapeDialog/scrapeResult";
import { sortStoredIdObjects } from "src/utils/data";
import ImageUtils from "src/utils/image";
import { uniq } from "lodash-es";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import {
ScrapedCustomFieldRows,
ScrapeDialogRow,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
import { StringListSelect } from "../Shared/Select";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
interface IStashIDsField {
values: GQL.StashId[];
}
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
return <StringListSelect value={values.map((v) => v.stash_id)} />;
};
interface ITagMergeDetailsProps {
sources: GQL.TagDataFragment[];
dest: GQL.TagDataFragment;
onClose: (values?: GQL.TagUpdateInput) => void;
}
const TagMergeDetails: React.FC<ITagMergeDetailsProps> = ({
sources,
dest,
onClose,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(true);
const filterCandidates = useCallback(
(t: { stored_id: string }) =>
t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id),
[dest.id, sources]
);
const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.name)
);
const [sortName, setSortName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.sort_name)
);
const [aliases, setAliases] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(dest.aliases)
);
const [description, setDescription] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.description)
);
const [parentTags, setParentTags] = useState<
ObjectListScrapeResult<GQL.ScrapedTag>
>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(
dest.parents.map(idToStoredID).filter(filterCandidates)
)
)
);
const [childTags, setChildTags] = useState<
ObjectListScrapeResult<GQL.ScrapedTag>
>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(
dest.children.map(idToStoredID).filter(filterCandidates)
)
)
);
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.image_path)
);
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
new Map()
);
function idToStoredID(o: { id: string; name: string }) {
return {
stored_id: o.id,
name: o.name,
};
}
// calculate the values for everything
// uses the first set value for single value fields, and combines all
useEffect(() => {
async function loadImages() {
const src = sources.find((s) => s.image_path);
if (!dest.image_path || !src) return;
setLoading(true);
const destData = await ImageUtils.imageToDataURL(dest.image_path);
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
// keep destination image by default
const useNewValue = false;
setImage(new ScrapeResult(destData, srcData, useNewValue));
setLoading(false);
}
// append dest to all so that if dest has stash_ids with the same
// endpoint, then it will be excluded first
const all = sources.concat(dest);
setName(
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
);
setSortName(
new ScrapeResult(
dest.sort_name,
sources.find((s) => s.sort_name)?.sort_name,
!dest.sort_name
)
);
setDescription(
new ScrapeResult(
dest.description,
sources.find((s) => s.description)?.description,
!dest.description
)
);
// default alias list should be the existing aliases, plus the names of all sources,
// plus all source aliases, deduplicated
const allAliases = uniq(
dest.aliases.concat(
sources.map((s) => s.name),
sources.flatMap((s) => s.aliases)
)
);
setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length));
// default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated
const allParentTags = uniq(all.flatMap((s) => s.parents))
.map(idToStoredID)
.filter(filterCandidates); // exclude self and sources
setParentTags(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(dest.parents.map(idToStoredID)),
sortStoredIdObjects(allParentTags),
!!allParentTags.length
)
);
const allChildTags = uniq(all.flatMap((s) => s.children))
.map(idToStoredID)
.filter(filterCandidates); // exclude self and sources
setChildTags(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(
dest.children.map(idToStoredID).filter(filterCandidates)
),
sortStoredIdObjects(allChildTags),
!!allChildTags.length
)
);
setStashIDs(
new ScrapeResult(
dest.stash_ids,
all
.map((s) => s.stash_ids)
.flat()
.filter((s, index, a) => {
// remove entries with duplicate endpoints
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
})
)
);
setImage(
new ScrapeResult(
dest.image_path,
sources.find((s) => s.image_path)?.image_path,
!dest.image_path
)
);
const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));
for (const s of sources) {
for (const n of Object.keys(s.custom_fields)) {
customFieldNames.add(n);
}
}
setCustomFields(
new Map(
Array.from(customFieldNames)
.sort()
.map((field) => {
return [
field,
new ScrapeResult(
dest.custom_fields?.[field],
sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
field
],
dest.custom_fields?.[field] === undefined
),
];
})
)
);
loadImages();
}, [sources, dest, filterCandidates]);
const hasCustomFieldValues = useMemo(() => {
return hasScrapedValues(Array.from(customFields.values()));
}, [customFields]);
// ensure this is updated if fields are changed
const hasValues = useMemo(() => {
return (
hasCustomFieldValues ||
hasScrapedValues([
name,
sortName,
aliases,
description,
parentTags,
childTags,
stashIDs,
image,
])
);
}, [
name,
sortName,
aliases,
description,
parentTags,
childTags,
stashIDs,
image,
hasCustomFieldValues,
]);
function renderScrapeRows() {
if (loading) {
return (
<div>
<LoadingIndicator />
</div>
);
}
if (!hasValues) {
return (
<div>
<FormattedMessage id="dialogs.merge.empty_results" />
</div>
);
}
return (
<>
<ScrapedInputGroupRow
field="name"
title={intl.formatMessage({ id: "name" })}
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedInputGroupRow
field="sort_name"
title={intl.formatMessage({ id: "sort_name" })}
result={sortName}
onChange={(value) => setSortName(value)}
/>
<ScrapedStringListRow
field="aliases"
title={intl.formatMessage({ id: "aliases" })}
result={aliases}
onChange={(value) => setAliases(value)}
/>
<ScrapedTagsRow
field="parent_tags"
title={intl.formatMessage({ id: "parent_tags" })}
result={parentTags}
onChange={(value) => setParentTags(value)}
/>
<ScrapedTagsRow
field="child_tags"
title={intl.formatMessage({ id: "sub_tags" })}
result={childTags}
onChange={(value) => setChildTags(value)}
/>
<ScrapedTextAreaRow
field="description"
title={intl.formatMessage({ id: "description" })}
result={description}
onChange={(value) => setDescription(value)}
/>
<ScrapeDialogRow
field="stash_ids"
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} />
}
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)}
/>
<ScrapedImageRow
field="image"
title={intl.formatMessage({ id: "tag_image" })}
className="performer-image"
result={image}
onChange={(value) => setImage(value)}
/>
{hasCustomFieldValues && (
<ScrapedCustomFieldRows
results={customFields}
onChange={(newCustomFields) => setCustomFields(newCustomFields)}
/>
)}
</>
);
}
function createValues(): GQL.TagUpdateInput {
// only set the cover image if it's different from the existing cover image
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
return {
id: dest.id,
name: name.getNewValue(),
sort_name: sortName.getNewValue(),
aliases: aliases
.getNewValue()
?.map((s) => s.trim())
.filter((s) => s.length > 0),
parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!),
child_ids: childTags.getNewValue()?.map((t) => t.stored_id!),
description: description.getNewValue(),
stash_ids: stashIDs.getNewValue(),
image: coverImage,
custom_fields: {
partial: Object.fromEntries(
Array.from(customFields.entries()).flatMap(([field, v]) =>
v.useNewValue ? [[field, v.getNewValue()]] : []
)
),
},
};
}
const dialogTitle = intl.formatMessage({
id: "actions.merge",
});
const destinationLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.destination" });
const sourceLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.source" });
return (
<ScrapeDialog
className="tag-merge-dialog"
title={dialogTitle}
existingLabel={destinationLabel}
scrapedLabel={sourceLabel}
onClose={(apply) => {
if (!apply) {
onClose();
} else {
onClose(createValues());
}
}}
>
{renderScrapeRows()}
</ScrapeDialog>
);
};
interface ITagMergeModalProps {
show: boolean;
@ -23,6 +422,11 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
const [src, setSrc] = useState<Tag[]>([]);
const [dest, setDest] = useState<Tag | null>(null);
const [loadedSources, setLoadedSources] = useState<GQL.TagDataFragment[]>([]);
const [loadedDest, setLoadedDest] = useState<GQL.TagDataFragment>();
const [secondStep, setSecondStep] = useState(false);
const [running, setRunning] = useState(false);
const [mergeTags] = useTagsMerge();
@ -41,7 +445,18 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
}
}, [tags]);
async function onMerge() {
async function loadTags() {
const tagIDs = src.map((s) => s.id);
tagIDs.push(dest!.id);
const query = await queryFindTagsByID(tagIDs);
const { tags: loadedTags } = query.data.findTags;
setLoadedDest(loadedTags.find((s) => s.id === dest!.id));
setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id));
setSecondStep(true);
}
async function onMerge(values: GQL.TagUpdateInput) {
if (!dest) return;
const source = src.map((s) => s.id);
@ -53,6 +468,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
variables: {
source,
destination,
values,
},
});
if (result.data?.tagsMerge) {
@ -78,6 +494,23 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
}
}
if (secondStep && dest) {
return (
<TagMergeDetails
sources={loadedSources}
dest={loadedDest!}
onClose={(values) => {
setSecondStep(false);
if (values) {
onMerge(values);
} else {
onClose();
}
}}
/>
);
}
return (
<ModalComponent
show={show}
@ -85,7 +518,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.merge" }),
onClick: () => onMerge(),
onClick: () => loadTags(),
}}
disabled={!canMerge()}
cancel={{

View file

@ -472,6 +472,14 @@ export const queryFindTagsForList = (filter: ListFilterModel) =>
},
});
export const queryFindTagsByID = (tagIDs: string[]) =>
client.query<GQL.FindTagsQuery>({
query: GQL.FindTagsDocument,
variables: {
ids: tagIDs,
},
});
export const queryFindTagsByIDForSelect = (tagIDs: string[]) =>
client.query<GQL.FindTagsForSelectQuery>({
query: GQL.FindTagsForSelectDocument,