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
This commit is contained in:
WithoutPants 2025-12-09 13:55:11 +11:00 committed by GitHub
parent 945d679158
commit 7fded66bfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 540 additions and 99 deletions

View File

@ -98,7 +98,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
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<IGalleryScrapeDialogProps> = ({
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(

View File

@ -98,7 +98,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
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<IGroupScrapeDialogProps> = ({
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(

View File

@ -100,7 +100,7 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
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<IImageScrapeDialogProps> = ({
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(

View File

@ -314,9 +314,10 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
)
);
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
props.performerTags,
props.scraped.tags
props.scraped.tags,
endpoint
);
const [image, setImage] = useState<ScrapeResult<string>>(
@ -542,6 +543,10 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(

View File

@ -131,7 +131,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
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<ISceneScrapeDialogProps> = ({
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(

View File

@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal";
import { faLink } from "@fortawesome/free-solid-svg-icons";
import { Form } from "react-bootstrap";
import { Tag, TagSelect } from "../../Tags/TagSelect";
export const CreateLinkTagDialog: React.FC<{
tag: GQL.ScrapedTag;
onClose: (result: {
create?: GQL.TagCreateInput;
update?: GQL.TagUpdateInput;
}) => void;
endpoint?: string;
}> = ({ tag, onClose, endpoint }) => {
const intl = useIntl();
const [createNew, setCreateNew] = useState(false);
const [name, setName] = useState(tag.name);
const [existingTag, setExistingTag] = useState<Tag | null>(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 (
<ModalComponent
show={true}
accept={{
text: intl.formatMessage({ id: "actions.save" }),
onClick: () => 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" })}
>
<Form>
<Form.Check
type="radio"
id="create-new"
label={intl.formatMessage({ id: "actions.create_new" })}
checked={createNew}
onChange={() => setCreateNew(true)}
/>
<Form.Group className="ml-3 mt-2">
<Form.Label>
<FormattedMessage id="name" />
</Form.Label>
<Form.Control
className="input-control"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!createNew}
/>
</Form.Group>
<Form.Check
type="radio"
id="link-existing"
label={intl.formatMessage({
id: "component_tagger.verb_link_existing",
})}
checked={!createNew}
onChange={() => setCreateNew(false)}
/>
<Form.Group className="ml-3 mt-2">
<TagSelect
isMulti={false}
values={existingTag ? [existingTag] : []}
onSelect={(t) => setExistingTag(t.length > 0 ? t[0] : null)}
isDisabled={createNew}
menuPortalTarget={document.body}
/>
</Form.Group>
<Form.Group className="mt-3">
<Form.Check
type="checkbox"
id="add-as-alias"
label={intl.formatMessage({
id: "component_tagger.verb_add_as_alias",
})}
checked={addAsAlias}
onChange={() => setAddAsAlias(!addAsAlias)}
disabled={!canAddAlias}
/>
</Form.Group>
</Form>
</ModalComponent>
);
};

View File

@ -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<T> {
newValues: T[];
onCreateNew: (value: T) => void;
onLinkExisting?: (value: T) => void;
getName: (value: T) => string;
}
@ -42,6 +43,17 @@ export const NewScrapedObjects = <T,>(props: INewScrapedObjects<T>) => {
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
{props.onLinkExisting ? (
<Button
className="minimal"
onClick={(e) => {
props.onLinkExisting?.(t);
e.stopPropagation();
}}
>
<Icon className="fa-fw" icon={faLink} />
</Button>
) : null}
</Badge>
))}
</>
@ -70,6 +82,7 @@ interface IScrapedStudioRow {
onChange: (value: ObjectScrapeResult<GQL.ScrapedStudio>) => void;
newStudio?: GQL.ScrapedStudio;
onCreateNew?: (value: GQL.ScrapedStudio) => void;
onLinkExisting?: (value: GQL.ScrapedStudio) => void;
}
function getObjectName<T extends { name: string }>(value: T) {
@ -83,6 +96,7 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
onChange,
newStudio,
onCreateNew,
onLinkExisting,
}) => {
function renderScrapedStudio(
scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>,
@ -140,6 +154,7 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
newValues={[newStudio]}
onCreateNew={onCreateNew}
getName={getObjectName}
onLinkExisting={onLinkExisting}
/>
) : undefined
}
@ -154,6 +169,7 @@ interface IScrapedObjectsRow<T> {
onChange: (value: ScrapeResult<T[]>) => void;
newObjects?: T[];
onCreateNew?: (value: T) => void;
onLinkExisting?: (value: T) => void;
renderObjects: (
result: ScrapeResult<T[]>,
isNew?: boolean,
@ -170,6 +186,7 @@ export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
onChange,
newObjects,
onCreateNew,
onLinkExisting,
renderObjects,
getName,
} = props;
@ -189,6 +206,7 @@ export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
<NewScrapedObjects
newValues={newObjects ?? []}
onCreateNew={onCreateNew}
onLinkExisting={onLinkExisting}
getName={getName}
/>
) : 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<GQL.ScrapedGroup>
> = ({ 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<GQL.ScrapedTag>
> = ({ title, field, result, onChange, newObjects, onCreateNew }) => {
> = ({
title,
field,
result,
onChange,
newObjects,
onCreateNew,
onLinkExisting,
}) => {
function renderScrapedTags(
scrapeResult: ScrapeResult<GQL.ScrapedTag[]>,
isNew?: boolean,
@ -375,6 +412,7 @@ export const ScrapedTagsRow: React.FC<
onChange={onChange}
newObjects={newObjects}
onCreateNew={onCreateNew}
onLinkExisting={onLinkExisting}
getName={getObjectName}
/>
);

View File

@ -183,12 +183,40 @@ export function useCreateScrapedGroup(
return useCreateObject("group", createNewGroup);
}
export function useLinkScrapedTag(
props: IUseCreateNewObjectProps<GQL.ScrapedTag>
) {
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<GQL.ScrapedTag>
) {
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);

View File

@ -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<ObjectListScrapeResult<GQL.ScrapedTag>>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(
@ -28,6 +33,7 @@ export function useScrapedTags(
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
scrapedTags?.filter((t) => !t.stored_id) ?? []
);
const [linkedTag, setLinkedTag] = useState<GQL.ScrapedTag | null>(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 ? (
<CreateLinkTagDialog
tag={linkedTag}
onClose={handleLinkTagResult}
endpoint={endpoint}
/>
) : null;
const scrapedTagsRow = (
<ScrapedTagsRow
field="tags"
@ -45,12 +124,14 @@ export function useScrapedTags(
onChange={(value) => setTags(value)}
newObjects={newTags}
onCreateNew={createNewTag}
onLinkExisting={(l) => setLinkedTag(l)}
/>
);
return {
tags,
newTags,
linkDialog,
scrapedTagsRow,
};
}

View File

@ -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<void>;
}> = ({ disabled, onLink }) => {
const intl = useIntl();
return (
<OperationButton
variant="secondary"
disabled={disabled}
operation={onLink}
hideChildrenWhenLoading
title={intl.formatMessage({ id: "component_tagger.verb_link_existing" })}
>
<Icon icon={faLink} />
</OperationButton>
);
};

View File

@ -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<string | undefined>;
updateStudio: (studio: GQL.StudioUpdateInput) => Promise<void>;
linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise<void>;
updateTag: (
tag: GQL.ScrapedTag,
updateInput: GQL.TagUpdateInput
) => Promise<void>;
resolveScene: (
sceneID: string,
index: number,
@ -92,6 +97,7 @@ export const TaggerStateContext = React.createContext<ITaggerContextState>({
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(<span>Updated tag</span>);
} catch (e) {
Toast.error(e);
}
}
return (
<TaggerStateContext.Provider
value={{
@ -884,6 +933,7 @@ export const TaggerContext: React.FC = ({ children }) => {
createNewStudio,
updateStudio: updateExistingStudio,
linkStudio,
updateTag: updateExistingTag,
resolveScene,
saveScene,
submitFingerprints,

View File

@ -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<IPerformerResultProps> = ({
);
}
function maybeRenderLinkButton() {
if (endpoint && onLink) {
return (
<OperationButton
variant="secondary"
disabled={selectedID === undefined}
operation={onLink}
hideChildrenWhenLoading
>
<Icon icon={faSave} />
</OperationButton>
);
}
}
const selectedSource = !selectedID ? "skip" : "existing";
const safeBuildPerformerScraperLink = (id: string | null | undefined) => {
@ -199,7 +182,9 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
isClearable={false}
ageFromDate={ageFromDate}
/>
{maybeRenderLinkButton()}
{endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}
</ButtonGroup>
</div>
);

View File

@ -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<IStashSearchResultProps> = ({
createNewStudio,
updateStudio,
linkStudio,
updateTag,
resolveScene,
currentSource,
saveScene,
@ -248,9 +250,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
[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<IStashSearchResultProps> = ({
});
}
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<IStashSearchResultProps> = ({
</div>
);
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<IStashSearchResultProps> = ({
}}
>
{t.name}
<Button className="minimal ml-2">
<Button
className="minimal ml-2"
title={intl.formatMessage({ id: "actions.create" })}
>
<Icon className="fa-fw" icon={faPlus} />
</Button>
<Button
className="minimal"
onClick={(e) => {
showTagModal(t);
e.stopPropagation();
}}
title={intl.formatMessage({
id: "component_tagger.verb_link_existing",
})}
>
<Icon className="fa-fw" icon={faLink} />
</Button>
</Badge>
))}
</div>

View File

@ -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<IStudioResultProps> = ({
);
}
function maybeRenderLinkButton() {
if (endpoint && onLink) {
return (
<OperationButton
variant="secondary"
disabled={selectedID === undefined}
operation={onLink}
hideChildrenWhenLoading
>
<Icon icon={faSave} />
</OperationButton>
);
}
}
const selectedSource = !selectedID ? "skip" : "existing";
const safeBuildStudioScraperLink = (id: string | null | undefined) => {
@ -169,7 +152,9 @@ const StudioResult: React.FC<IStudioResultProps> = ({
})}
isClearable={false}
/>
{maybeRenderLinkButton()}
{endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}
</ButtonGroup>
</div>
);

View File

@ -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<ISceneTaggerModalsContextState>({
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<GQL.ScrapedTag | undefined>();
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 (
<SceneTaggerModalsState.Provider
value={{ createPerformerModal, createStudioModal }}
value={{ createPerformerModal, createStudioModal, createTagModal }}
>
{performerToCreate && (
<PerformerModal
@ -141,6 +174,13 @@ export const SceneTaggerModals: React.FC = ({ children }) => {
endpoint={endpoint}
/>
)}
{tagToCreate && (
<CreateLinkTagDialog
tag={tagToCreate}
onClose={handleTagSave}
endpoint={endpoint}
/>
)}
{children}
</SceneTaggerModalsState.Provider>
);

View File

@ -153,9 +153,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
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<ITagEditPanel> = ({
// TODO: CSS class
return (
<>
{/* allow many stash-ids from the same stash box */}
{isStashIDSearchOpen && (
<StashBoxIDSearchModal
entityType="tag"
stashBoxes={stashConfig?.general.stashBoxes ?? []}
excludedStashBoxEndpoints={formik.values.stash_ids.map(
(s) => s.endpoint
)}
onSelectItem={(item) => {
onStashIDSelected(item);
setIsStashIDSearchOpen(false);

View File

@ -150,3 +150,21 @@ export const useToast = () => {
[addToast]
);
};
export function toastOperation(
toast: ReturnType<typeof useToast>,
o: () => Promise<void>,
successMessage: string
) {
async function operation() {
try {
await o();
toast.success(successMessage);
} catch (e) {
toast.error(e);
}
}
return operation;
}

View File

@ -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}}",

View File

@ -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];
};