From 7fded66bfa1029a5fe87ef89cd9d14817111aa0d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:55:11 +1100 Subject: [PATCH] Improve tag stash-id handling in tagger and scraper dialogs (#6389) * Change link button icon and separate into component * Add create/link tag dialog * Add titles to buttons * Add ability to link existing tags in scrape dialogs * Move create link dialog * Allow tags to have multiple stash-ids from the same endpoint --- .../GalleryDetails/GalleryScrapeDialog.tsx | 6 +- .../Groups/GroupDetails/GroupScrapeDialog.tsx | 6 +- .../Images/ImageDetails/ImageScrapeDialog.tsx | 6 +- .../PerformerScrapeDialog.tsx | 9 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 6 +- .../ScrapeDialog/CreateLinkTagDialog.tsx | 132 ++++++++++++++++++ .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 44 +++++- .../Shared/ScrapeDialog/createObjects.ts | 55 +++++--- .../Shared/ScrapeDialog/scrapedTags.tsx | 83 ++++++++++- ui/v2.5/src/components/Tagger/LinkButton.tsx | 25 ++++ ui/v2.5/src/components/Tagger/context.tsx | 50 +++++++ .../Tagger/scenes/PerformerResult.tsx | 23 +-- .../Tagger/scenes/StashSearchResult.tsx | 85 +++++++---- .../components/Tagger/scenes/StudioResult.tsx | 23 +-- .../Tagger/scenes/sceneTaggerModals.tsx | 42 +++++- .../Tags/TagDetails/TagEditPanel.tsx | 7 +- ui/v2.5/src/hooks/Toast.tsx | 18 +++ ui/v2.5/src/locales/en-GB.json | 4 + ui/v2.5/src/utils/stashIds.ts | 15 +- 19 files changed, 540 insertions(+), 99 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx create mode 100644 ui/v2.5/src/components/Tagger/LinkButton.tsx diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 6bc67b9bc..3ceec4aa1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -98,7 +98,7 @@ export const GalleryScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( galleryTags, scraped.tags ); @@ -219,6 +219,10 @@ export const GalleryScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( = ({ setNewObject: setNewStudio, }); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( groupTags, scraped.tags ); @@ -218,6 +218,10 @@ export const GroupScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( imageTags, scraped.tags ); @@ -220,6 +220,10 @@ export const ImageScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( = ( ) ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( props.performerTags, - props.scraped.tags + props.scraped.tags, + endpoint ); const [image, setImage] = useState>( @@ -542,6 +543,10 @@ export const PerformerScrapeDialog: React.FC = ( ); } + if (linkDialog) { + return linkDialog; + } + return ( = ({ scraped.groups?.filter((t) => !t.stored_id) ?? [] ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( sceneTags, scraped.tags, endpoint @@ -298,6 +298,10 @@ export const SceneScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( void; + endpoint?: string; +}> = ({ tag, onClose, endpoint }) => { + const intl = useIntl(); + + const [createNew, setCreateNew] = useState(false); + const [name, setName] = useState(tag.name); + const [existingTag, setExistingTag] = useState(null); + const [addAsAlias, setAddAsAlias] = useState(false); + + const canAddAlias = (createNew && name !== tag.name) || !createNew; + + useEffect(() => { + setAddAsAlias(canAddAlias); + }, [canAddAlias]); + + function handleTagSave() { + if (createNew) { + const createInput: GQL.TagCreateInput = { + name: name, + aliases: addAsAlias ? [tag.name] : [], + stash_ids: + endpoint && tag.remote_site_id + ? [{ endpoint: endpoint!, stash_id: tag.remote_site_id }] + : undefined, + }; + onClose({ create: createInput }); + } else if (existingTag) { + const updateInput: GQL.TagUpdateInput = { + id: existingTag.id, + aliases: addAsAlias + ? [...(existingTag.aliases || []), tag.name] + : undefined, + stash_ids: + endpoint && tag.remote_site_id + ? [{ endpoint: endpoint!, stash_id: tag.remote_site_id }] + : undefined, + }; + onClose({ update: updateInput }); + } + } + + return ( + handleTagSave(), + }} + disabled={createNew ? name.trim() === "" : existingTag === null} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + onClick: () => { + onClose({}); + }, + }} + dialogClassName="create-link-tag-modal" + icon={faLink} + header={intl.formatMessage({ id: "component_tagger.verb_match_tag" })} + > +
+ setCreateNew(true)} + /> + + + + + + setName(e.target.value)} + disabled={!createNew} + /> + + + setCreateNew(false)} + /> + + + setExistingTag(t.length > 0 ? t[0] : null)} + isDisabled={createNew} + menuPortalTarget={document.body} + /> + + + + setAddAsAlias(!addAsAlias)} + disabled={!canAddAlias} + /> + + +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 1588b8829..fea2fd541 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -13,12 +13,13 @@ import { uniq } from "lodash-es"; import { CollapseButton } from "../CollapseButton"; import { Badge, Button } from "react-bootstrap"; import { Icon } from "../Icon"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; interface INewScrapedObjects { newValues: T[]; onCreateNew: (value: T) => void; + onLinkExisting?: (value: T) => void; getName: (value: T) => string; } @@ -42,6 +43,17 @@ export const NewScrapedObjects = (props: INewScrapedObjects) => { + {props.onLinkExisting ? ( + + ) : null} ))} @@ -70,6 +82,7 @@ interface IScrapedStudioRow { onChange: (value: ObjectScrapeResult) => void; newStudio?: GQL.ScrapedStudio; onCreateNew?: (value: GQL.ScrapedStudio) => void; + onLinkExisting?: (value: GQL.ScrapedStudio) => void; } function getObjectName(value: T) { @@ -83,6 +96,7 @@ export const ScrapedStudioRow: React.FC = ({ onChange, newStudio, onCreateNew, + onLinkExisting, }) => { function renderScrapedStudio( scrapeResult: ObjectScrapeResult, @@ -140,6 +154,7 @@ export const ScrapedStudioRow: React.FC = ({ newValues={[newStudio]} onCreateNew={onCreateNew} getName={getObjectName} + onLinkExisting={onLinkExisting} /> ) : undefined } @@ -154,6 +169,7 @@ interface IScrapedObjectsRow { onChange: (value: ScrapeResult) => void; newObjects?: T[]; onCreateNew?: (value: T) => void; + onLinkExisting?: (value: T) => void; renderObjects: ( result: ScrapeResult, isNew?: boolean, @@ -170,6 +186,7 @@ export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { onChange, newObjects, onCreateNew, + onLinkExisting, renderObjects, getName, } = props; @@ -189,6 +206,7 @@ export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { ) : undefined @@ -212,6 +230,7 @@ export const ScrapedPerformersRow: React.FC< newObjects, onCreateNew, ageFromDate, + onLinkExisting, }) => { const performersCopy = useMemo(() => { return ( @@ -268,13 +287,22 @@ export const ScrapedPerformersRow: React.FC< newObjects={performersCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} + onLinkExisting={onLinkExisting} /> ); }; export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { +> = ({ + title, + field, + result, + onChange, + newObjects, + onCreateNew, + onLinkExisting, +}) => { const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { @@ -329,13 +357,22 @@ export const ScrapedGroupsRow: React.FC< newObjects={groupsCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} + onLinkExisting={onLinkExisting} /> ); }; export const ScrapedTagsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { +> = ({ + title, + field, + result, + onChange, + newObjects, + onCreateNew, + onLinkExisting, +}) => { function renderScrapedTags( scrapeResult: ScrapeResult, isNew?: boolean, @@ -375,6 +412,7 @@ export const ScrapedTagsRow: React.FC< onChange={onChange} newObjects={newObjects} onCreateNew={onCreateNew} + onLinkExisting={onLinkExisting} getName={getObjectName} /> ); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index e2d09294a..f16ecf2f2 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -183,12 +183,40 @@ export function useCreateScrapedGroup( return useCreateObject("group", createNewGroup); } +export function useLinkScrapedTag( + props: IUseCreateNewObjectProps +) { + const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + + function linkTag(id: string, matchedName: string, scrapedName: string) { + const newValue = [...(scrapeResult.newValue ?? [])]; + newValue.push({ + stored_id: id, + name: matchedName, + }); + + // add the new tag to the new tags value + const tagClone = scrapeResult.cloneWithValue(newValue); + setScrapeResult(tagClone); + + // remove the tag from the list + const newTagsClone = newObjects.concat(); + const pIndex = newTagsClone.findIndex((p) => p.name === scrapedName); + if (pIndex === -1) throw new Error("Could not find tag to remove"); + + newTagsClone.splice(pIndex, 1); + + setNewObjects(newTagsClone); + } + + return linkTag; +} + export function useCreateScrapedTag( props: IUseCreateNewObjectProps ) { const [createTag] = useTagCreate(); - - const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + const linkTag = useLinkScrapedTag(props); async function createNewTag(toCreate: GQL.ScrapedTag) { const input: GQL.TagCreateInput = { @@ -208,25 +236,12 @@ export function useCreateScrapedTag( variables: { input }, }); - const newValue = [...(scrapeResult.newValue ?? [])]; if (result.data?.tagCreate) - newValue.push({ - stored_id: result.data.tagCreate.id, - name: result.data.tagCreate.name, - }); - - // add the new tag to the new tags value - const tagClone = scrapeResult.cloneWithValue(newValue); - setScrapeResult(tagClone); - - // remove the tag from the list - const newTagsClone = newObjects.concat(); - const pIndex = newTagsClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find tag to remove"); - - newTagsClone.splice(pIndex, 1); - - setNewObjects(newTagsClone); + linkTag( + result.data.tagCreate.id, + result.data.tagCreate.name, + toCreate.name ?? "" + ); } return useCreateObject("tag", createNewTag); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx index f298a6eeb..8ab88878d 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx @@ -4,8 +4,11 @@ import * as GQL from "src/core/generated-graphql"; import { ObjectListScrapeResult } from "./scrapeResult"; import { sortStoredIdObjects } from "src/utils/data"; import { Tag } from "src/components/Tags/TagSelect"; -import { useCreateScrapedTag } from "./createObjects"; +import { useCreateScrapedTag, useLinkScrapedTag } from "./createObjects"; import { ScrapedTagsRow } from "./ScrapedObjectsRow"; +import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; +import { useTagCreate, useTagUpdate } from "src/core/StashService"; +import { toastOperation, useToast } from "src/hooks/Toast"; export function useScrapedTags( existingTags: Tag[], @@ -13,6 +16,8 @@ export function useScrapedTags( endpoint?: string ) { const intl = useIntl(); + const Toast = useToast(); + const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects( @@ -28,6 +33,7 @@ export function useScrapedTags( const [newTags, setNewTags] = useState( scrapedTags?.filter((t) => !t.stored_id) ?? [] ); + const [linkedTag, setLinkedTag] = useState(null); const createNewTag = useCreateScrapedTag({ scrapeResult: tags, @@ -37,6 +43,79 @@ export function useScrapedTags( endpoint, }); + const [createTag] = useTagCreate(); + const [updateTag] = useTagUpdate(); + + const linkScrapedTag = useLinkScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); + + async function handleLinkTagResult(tag: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) { + if (tag.create) { + await toastOperation( + Toast, + async () => { + // create the new tag + const result = await createTag({ variables: { input: tag.create! } }); + + // adjust scrape result + if (result.data?.tagCreate) { + linkScrapedTag( + result.data.tagCreate.id, + result.data.tagCreate.name, + linkedTag?.name ?? "" + ); + } + }, + intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + } + ) + )(); + } else if (tag.update) { + // link existing tag + await toastOperation( + Toast, + async () => { + const result = await updateTag({ variables: { input: tag.update! } }); + + // adjust scrape result + if (result.data?.tagUpdate) { + linkScrapedTag( + result.data.tagUpdate.id, + result.data.tagUpdate.name, + linkedTag?.name ?? "" + ); + } + }, + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + } + ) + )(); + } + + setLinkedTag(null); + } + + const linkDialog = linkedTag ? ( + + ) : null; + const scrapedTagsRow = ( setTags(value)} newObjects={newTags} onCreateNew={createNewTag} + onLinkExisting={(l) => setLinkedTag(l)} /> ); return { tags, newTags, + linkDialog, scrapedTagsRow, }; } diff --git a/ui/v2.5/src/components/Tagger/LinkButton.tsx b/ui/v2.5/src/components/Tagger/LinkButton.tsx new file mode 100644 index 000000000..c2c544e71 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/LinkButton.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { useIntl } from "react-intl"; +import { faLink } from "@fortawesome/free-solid-svg-icons"; + +import { OperationButton } from "../Shared/OperationButton"; +import { Icon } from "../Shared/Icon"; + +export const LinkButton: React.FC<{ + disabled: boolean; + onLink: () => Promise; +}> = ({ disabled, onLink }) => { + const intl = useIntl(); + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 8557cc94a..fbf20ee40 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -15,6 +15,7 @@ import { useStudioCreate, useStudioUpdate, useTagCreate, + useTagUpdate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; @@ -55,6 +56,10 @@ export interface ITaggerContextState { ) => Promise; updateStudio: (studio: GQL.StudioUpdateInput) => Promise; linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise; + updateTag: ( + tag: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) => Promise; resolveScene: ( sceneID: string, index: number, @@ -92,6 +97,7 @@ export const TaggerStateContext = React.createContext({ createNewStudio: dummyValFn, updateStudio: dummyFn, linkStudio: dummyFn, + updateTag: dummyFn, resolveScene: dummyFn, submitFingerprints: dummyFn, pendingFingerprints: [], @@ -129,6 +135,7 @@ export const TaggerContext: React.FC = ({ children }) => { const [createStudio] = useStudioCreate(); const [updateStudio] = useStudioUpdate(); const [updateScene] = useSceneUpdate(); + const [updateTag] = useTagUpdate(); useEffect(() => { if (!stashConfig || !Scrapers.data) { @@ -860,6 +867,48 @@ export const TaggerContext: React.FC = ({ children }) => { } } + async function updateExistingTag( + tag: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) { + if (!tag.remote_site_id || !currentSource?.sourceInput.stash_box_endpoint) + return; + + try { + await updateTag({ + variables: { + input: updateInput, + }, + }); + + const newSearchResults = mapResults((r) => { + if (!r.tags) { + return r; + } + + return { + ...r, + tags: r.tags.map((t) => { + if (t.remote_site_id === tag.remote_site_id) { + return { + ...t, + stored_id: updateInput.id, + }; + } + + return t; + }), + }; + }); + + setSearchResults(newSearchResults); + + Toast.success(Updated tag); + } catch (e) { + Toast.error(e); + } + } + return ( { createNewStudio, updateStudio: updateExistingStudio, linkStudio, + updateTag: updateExistingTag, resolveScene, saveScene, submitFingerprints, diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 55e7f7f25..53caba2ff 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -3,10 +3,7 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; -import { Icon } from "src/components/Shared/Icon"; -import { OperationButton } from "src/components/Shared/OperationButton"; import { OptionalField } from "../IncludeButton"; -import { faSave } from "@fortawesome/free-solid-svg-icons"; import { Performer, PerformerSelect, @@ -14,6 +11,7 @@ import { import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; +import { LinkButton } from "../LinkButton"; const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; @@ -148,21 +146,6 @@ const PerformerResult: React.FC = ({ ); } - function maybeRenderLinkButton() { - if (endpoint && onLink) { - return ( - - - - ); - } - } - const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { @@ -199,7 +182,9 @@ const PerformerResult: React.FC = ({ isClearable={false} ageFromDate={ageFromDate} /> - {maybeRenderLinkButton()} + {endpoint && onLink && ( + + )} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index d9f05b875..01ce23643 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -7,6 +7,7 @@ import { blobToBase64 } from "base64-blob"; import { distance } from "src/utils/hamming"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { + faLink, faPlus, faTriangleExclamation, faXmark, @@ -232,6 +233,7 @@ const StashSearchResult: React.FC = ({ createNewStudio, updateStudio, linkStudio, + updateTag, resolveScene, currentSource, saveScene, @@ -248,9 +250,8 @@ const StashSearchResult: React.FC = ({ [scene, performerGenders] ); - const { createPerformerModal, createStudioModal } = React.useContext( - SceneTaggerModalsState - ); + const { createPerformerModal, createStudioModal, createTagModal } = + React.useContext(SceneTaggerModalsState); const getInitialTags = useCallback(() => { const stashSceneTags = stashScene.tags.map((t) => t.id); @@ -439,6 +440,47 @@ const StashSearchResult: React.FC = ({ }); } + async function onCreateTag( + t: GQL.ScrapedTag, + createInput?: GQL.TagCreateInput + ) { + const toCreate: GQL.TagCreateInput = createInput ?? { name: t.name }; + + // If the tag has a remote_site_id and we have an endpoint, include the stash_id + const endpoint = currentSource?.sourceInput.stash_box_endpoint; + if (!createInput && t.remote_site_id && endpoint) { + toCreate.stash_ids = [ + { + endpoint: endpoint, + stash_id: t.remote_site_id, + }, + ]; + } + + const newTagID = await createNewTag(t, toCreate); + if (newTagID !== undefined) { + setTagIDs([...tagIDs, newTagID]); + } + } + + async function onUpdateTag( + t: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) { + await updateTag(t, updateInput); + setTagIDs([...tagIDs, updateInput.id]); + } + + function showTagModal(t: GQL.ScrapedTag) { + createTagModal(t, (result) => { + if (result.create) { + onCreateTag(t, result.create); + } else if (result.update) { + onUpdateTag(t, result.update); + } + }); + } + async function studioModalCallback( studio: GQL.ScrapedStudio, toCreate?: GQL.StudioCreateInput, @@ -711,26 +753,6 @@ const StashSearchResult: React.FC = ({ ); - async function onCreateTag(t: GQL.ScrapedTag) { - const toCreate: GQL.TagCreateInput = { name: t.name }; - - // If the tag has a remote_site_id and we have an endpoint, include the stash_id - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (t.remote_site_id && endpoint) { - toCreate.stash_ids = [ - { - endpoint: endpoint, - stash_id: t.remote_site_id, - }, - ]; - } - - const newTagID = await createNewTag(t, toCreate); - if (newTagID !== undefined) { - setTagIDs([...tagIDs, newTagID]); - } - } - function maybeRenderTagsField() { if (!config.setTags) return; @@ -764,9 +786,24 @@ const StashSearchResult: React.FC = ({ }} > {t.name} - + ))} diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx index 410ce2d19..c37df2258 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx @@ -3,16 +3,14 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; -import { Icon } from "src/components/Shared/Icon"; -import { OperationButton } from "src/components/Shared/OperationButton"; import { StudioSelect, SelectObject } from "src/components/Shared/Select"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; -import { faSave } from "@fortawesome/free-solid-svg-icons"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; +import { LinkButton } from "../LinkButton"; const StudioLink: React.FC<{ studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; @@ -117,21 +115,6 @@ const StudioResult: React.FC = ({ ); } - function maybeRenderLinkButton() { - if (endpoint && onLink) { - return ( - - - - ); - } - } - const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildStudioScraperLink = (id: string | null | undefined) => { @@ -169,7 +152,9 @@ const StudioResult: React.FC = ({ })} isClearable={false} /> - {maybeRenderLinkButton()} + {endpoint && onLink && ( + + )} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx index 816e4e294..29339a9fc 100644 --- a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx @@ -6,12 +6,17 @@ import PerformerModal from "../PerformerModal"; import { TaggerStateContext } from "../context"; import { useIntl } from "react-intl"; import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type StudioModalCallback = ( toCreate?: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => void; +type TagModalCallback = (result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; +}) => void; export interface ISceneTaggerModalsContextState { createPerformerModal: ( @@ -22,12 +27,14 @@ export interface ISceneTaggerModalsContextState { studio: GQL.ScrapedSceneStudioDataFragment, callback: StudioModalCallback ) => void; + createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void; } export const SceneTaggerModalsState = React.createContext({ createPerformerModal: () => {}, createStudioModal: () => {}, + createTagModal: () => {}, }); export const SceneTaggerModals: React.FC = ({ children }) => { @@ -47,6 +54,15 @@ export const SceneTaggerModals: React.FC = ({ children }) => { StudioModalCallback | undefined >(); + const [tagToCreate, setTagToCreate] = useState(); + const [tagCallback, setTagCallback] = useState< + | ((result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) => void) + | undefined + >(); + const intl = useIntl(); function handlePerformerSave(toCreate: GQL.PerformerCreateInput) { @@ -106,11 +122,28 @@ export const SceneTaggerModals: React.FC = ({ children }) => { setStudioCallback(() => callback); } + function handleTagSave(result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) { + if (tagCallback) { + tagCallback(result); + } + + setTagToCreate(undefined); + setTagCallback(undefined); + } + + function createTagModal(tag: GQL.ScrapedTag, callback: TagModalCallback) { + setTagToCreate(tag); + setTagCallback(() => callback); + } + const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined; return ( {performerToCreate && ( { endpoint={endpoint} /> )} + {tagToCreate && ( + + )} {children} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 35733394a..e734de846 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -153,9 +153,10 @@ export const TagEditPanel: React.FC = ({ function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; + const allowMultiple = true; formik.setFieldValue( "stash_ids", - addUpdateStashID(formik.values.stash_ids, item) + addUpdateStashID(formik.values.stash_ids, item, allowMultiple) ); } @@ -203,13 +204,11 @@ export const TagEditPanel: React.FC = ({ // TODO: CSS class return ( <> + {/* allow many stash-ids from the same stash box */} {isStashIDSearchOpen && ( s.endpoint - )} onSelectItem={(item) => { onStashIDSelected(item); setIsStashIDSearchOpen(false); diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index 9be27e928..3590e0efb 100644 --- a/ui/v2.5/src/hooks/Toast.tsx +++ b/ui/v2.5/src/hooks/Toast.tsx @@ -150,3 +150,21 @@ export const useToast = () => { [addToast] ); }; + +export function toastOperation( + toast: ReturnType, + o: () => Promise, + successMessage: string +) { + async function operation() { + try { + await o(); + + toast.success(successMessage); + } catch (e) { + toast.error(e); + } + } + + return operation; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index a78ca55ec..2911ccb6e 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -34,6 +34,7 @@ "create_chapters": "Create Chapter", "create_entity": "Create {entityType}", "create_marker": "Create Marker", + "create_new": "Create new", "create_parent_studio": "Create parent studio", "created_entity": "Created {entity_type}: {entity_name}", "customise": "Customise", @@ -225,7 +226,10 @@ "phash_matches": "{count} PHashes match", "unnamed": "Unnamed" }, + "verb_add_as_alias": "Add scraped name as alias", + "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints", + "verb_match_tag": "Match Tag", "verb_matched": "Matched", "verb_scrape_all": "Scrape All", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index 92a4eaf1e..10e3835b8 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -42,17 +42,28 @@ export const separateNamesAndStashIds = ( */ export const addUpdateStashID = ( existingStashIDs: GQL.StashIdInput[], - newItem: GQL.StashIdInput + newItem: GQL.StashIdInput, + allowMultiple: boolean = false ): GQL.StashIdInput[] => { const existingIndex = existingStashIDs.findIndex( (s) => s.endpoint === newItem.endpoint ); - if (existingIndex >= 0) { + if (!allowMultiple && existingIndex >= 0) { const newStashIDs = [...existingStashIDs]; newStashIDs[existingIndex] = newItem; return newStashIDs; } + // ensure we don't add duplicates if allowMultiple is true + if ( + allowMultiple && + existingStashIDs.some( + (s) => s.endpoint === newItem.endpoint && s.stash_id === newItem.stash_id + ) + ) { + return existingStashIDs; + } + return [...existingStashIDs, newItem]; };