diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 2210c900e..0acbc927f 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -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 { diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 31c7980f6..ac0183b74 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -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 diff --git a/pkg/tag/update.go b/pkg/tag/update.go index 99e9b9165..4a3a2901a 100644 --- a/pkg/tag/update.go +++ b/pkg/tag/update.go @@ -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 -} diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index e640af0c9..19438e2a4 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -34,6 +34,8 @@ fragment TagData on Tag { children { ...SlimTagData } + + custom_fields } fragment SelectTagData on Tag { diff --git a/ui/v2.5/graphql/mutations/tag.graphql b/ui/v2.5/graphql/mutations/tag.graphql index f2138e057..33c50833a 100644 --- a/ui/v2.5/graphql/mutations/tag.graphql +++ b/ui/v2.5/graphql/mutations/tag.graphql @@ -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 } } diff --git a/ui/v2.5/graphql/queries/tag.graphql b/ui/v2.5/graphql/queries/tag.graphql index e0b20ee02..c91315f99 100644 --- a/ui/v2.5/graphql/queries/tag.graphql +++ b/ui/v2.5/graphql/queries/tag.graphql @@ -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 diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index 834d2ac76..ab4a6fed5 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -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>; - -// 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 ( - { - const newResults = new Map(results); - newResults.set(field, newResult); - onChange(newResults); - }} - /> - ); - })} - - ); -} - type MergeOptions = { values: GQL.PerformerUpdateInput; }; @@ -604,10 +572,12 @@ const PerformerMergeDetails: React.FC = ({ result={image} onChange={(value) => setImage(value)} /> - {hasCustomFieldValues && - renderScrapedCustomFieldRows(customFields, (newCustomFields) => - setCustomFields(newCustomFields) - )} + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx index 88b79d87d..677ecb87f 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -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 = ({ 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 ( + { + const newResults = new Map(results); + newResults.set(field, newResult); + onChange(newResults); + }} + /> + ); + })} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts index b9b88cef0..63d1c76c1 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts @@ -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>; + export class ScrapeResult { public newValue?: T; public originalValue?: T; diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx index 15b648af5..882e5ab50 100644 --- a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -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 = ({ values }) => { + return v.stash_id)} />; +}; + +interface ITagMergeDetailsProps { + sources: GQL.TagDataFragment[]; + dest: GQL.TagDataFragment; + onClose: (values?: GQL.TagUpdateInput) => void; +} + +const TagMergeDetails: React.FC = ({ + 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>( + new ScrapeResult(dest.name) + ); + const [sortName, setSortName] = useState>( + new ScrapeResult(dest.sort_name) + ); + const [aliases, setAliases] = useState>( + new ScrapeResult(dest.aliases) + ); + const [description, setDescription] = useState>( + new ScrapeResult(dest.description) + ); + const [parentTags, setParentTags] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects( + dest.parents.map(idToStoredID).filter(filterCandidates) + ) + ) + ); + const [childTags, setChildTags] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects( + dest.children.map(idToStoredID).filter(filterCandidates) + ) + ) + ); + + const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); + + const [image, setImage] = useState>( + new ScrapeResult(dest.image_path) + ); + + const [customFields, setCustomFields] = useState( + 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( + 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( + 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(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 ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setName(value)} + /> + setSortName(value)} + /> + setAliases(value)} + /> + setParentTags(value)} + /> + setChildTags(value)} + /> + setDescription(value)} + /> + + } + newField={} + onChange={(value) => setStashIDs(value)} + /> + setImage(value)} + /> + {hasCustomFieldValues && ( + 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 ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + > + {renderScrapeRows()} + + ); +}; interface ITagMergeModalProps { show: boolean; @@ -23,6 +422,11 @@ export const TagMergeModal: React.FC = ({ const [src, setSrc] = useState([]); const [dest, setDest] = useState(null); + const [loadedSources, setLoadedSources] = useState([]); + const [loadedDest, setLoadedDest] = useState(); + + const [secondStep, setSecondStep] = useState(false); + const [running, setRunning] = useState(false); const [mergeTags] = useTagsMerge(); @@ -41,7 +445,18 @@ export const TagMergeModal: React.FC = ({ } }, [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 = ({ variables: { source, destination, + values, }, }); if (result.data?.tagsMerge) { @@ -78,6 +494,23 @@ export const TagMergeModal: React.FC = ({ } } + if (secondStep && dest) { + return ( + { + setSecondStep(false); + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + return ( = ({ icon={faSignInAlt} accept={{ text: intl.formatMessage({ id: "actions.merge" }), - onClick: () => onMerge(), + onClick: () => loadTags(), }} disabled={!canMerge()} cancel={{ diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 6aaf17125..58b1aae42 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -472,6 +472,14 @@ export const queryFindTagsForList = (filter: ListFilterModel) => }, }); +export const queryFindTagsByID = (tagIDs: string[]) => + client.query({ + query: GQL.FindTagsDocument, + variables: { + ids: tagIDs, + }, + }); + export const queryFindTagsByIDForSelect = (tagIDs: string[]) => client.query({ query: GQL.FindTagsForSelectDocument,