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) ?? [] scraped.performers?.filter((t) => !t.stored_id) ?? []
); );
const { tags, newTags, scrapedTagsRow } = useScrapedTags( const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
galleryTags, galleryTags,
scraped.tags scraped.tags
); );
@ -219,6 +219,10 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
); );
} }
if (linkDialog) {
return linkDialog;
}
return ( return (
<ScrapeDialog <ScrapeDialog
title={intl.formatMessage( title={intl.formatMessage(

View File

@ -98,7 +98,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
setNewObject: setNewStudio, setNewObject: setNewStudio,
}); });
const { tags, newTags, scrapedTagsRow } = useScrapedTags( const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
groupTags, groupTags,
scraped.tags scraped.tags
); );
@ -218,6 +218,10 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
); );
} }
if (linkDialog) {
return linkDialog;
}
return ( return (
<ScrapeDialog <ScrapeDialog
title={intl.formatMessage( title={intl.formatMessage(

View File

@ -100,7 +100,7 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
scraped.performers?.filter((t) => !t.stored_id) ?? [] scraped.performers?.filter((t) => !t.stored_id) ?? []
); );
const { tags, newTags, scrapedTagsRow } = useScrapedTags( const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
imageTags, imageTags,
scraped.tags scraped.tags
); );
@ -220,6 +220,10 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
); );
} }
if (linkDialog) {
return linkDialog;
}
return ( return (
<ScrapeDialog <ScrapeDialog
title={intl.formatMessage( 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.performerTags,
props.scraped.tags props.scraped.tags,
endpoint
); );
const [image, setImage] = useState<ScrapeResult<string>>( const [image, setImage] = useState<ScrapeResult<string>>(
@ -542,6 +543,10 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
); );
} }
if (linkDialog) {
return linkDialog;
}
return ( return (
<ScrapeDialog <ScrapeDialog
title={intl.formatMessage( title={intl.formatMessage(

View File

@ -131,7 +131,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
scraped.groups?.filter((t) => !t.stored_id) ?? [] scraped.groups?.filter((t) => !t.stored_id) ?? []
); );
const { tags, newTags, scrapedTagsRow } = useScrapedTags( const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
sceneTags, sceneTags,
scraped.tags, scraped.tags,
endpoint endpoint
@ -298,6 +298,10 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
); );
} }
if (linkDialog) {
return linkDialog;
}
return ( return (
<ScrapeDialog <ScrapeDialog
title={intl.formatMessage( 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 { CollapseButton } from "../CollapseButton";
import { Badge, Button } from "react-bootstrap"; import { Badge, Button } from "react-bootstrap";
import { Icon } from "../Icon"; 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"; import { useIntl } from "react-intl";
interface INewScrapedObjects<T> { interface INewScrapedObjects<T> {
newValues: T[]; newValues: T[];
onCreateNew: (value: T) => void; onCreateNew: (value: T) => void;
onLinkExisting?: (value: T) => void;
getName: (value: T) => string; getName: (value: T) => string;
} }
@ -42,6 +43,17 @@ export const NewScrapedObjects = <T,>(props: INewScrapedObjects<T>) => {
<Button className="minimal ml-2"> <Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} /> <Icon className="fa-fw" icon={faPlus} />
</Button> </Button>
{props.onLinkExisting ? (
<Button
className="minimal"
onClick={(e) => {
props.onLinkExisting?.(t);
e.stopPropagation();
}}
>
<Icon className="fa-fw" icon={faLink} />
</Button>
) : null}
</Badge> </Badge>
))} ))}
</> </>
@ -70,6 +82,7 @@ interface IScrapedStudioRow {
onChange: (value: ObjectScrapeResult<GQL.ScrapedStudio>) => void; onChange: (value: ObjectScrapeResult<GQL.ScrapedStudio>) => void;
newStudio?: GQL.ScrapedStudio; newStudio?: GQL.ScrapedStudio;
onCreateNew?: (value: GQL.ScrapedStudio) => void; onCreateNew?: (value: GQL.ScrapedStudio) => void;
onLinkExisting?: (value: GQL.ScrapedStudio) => void;
} }
function getObjectName<T extends { name: string }>(value: T) { function getObjectName<T extends { name: string }>(value: T) {
@ -83,6 +96,7 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
onChange, onChange,
newStudio, newStudio,
onCreateNew, onCreateNew,
onLinkExisting,
}) => { }) => {
function renderScrapedStudio( function renderScrapedStudio(
scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>, scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>,
@ -140,6 +154,7 @@ export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
newValues={[newStudio]} newValues={[newStudio]}
onCreateNew={onCreateNew} onCreateNew={onCreateNew}
getName={getObjectName} getName={getObjectName}
onLinkExisting={onLinkExisting}
/> />
) : undefined ) : undefined
} }
@ -154,6 +169,7 @@ interface IScrapedObjectsRow<T> {
onChange: (value: ScrapeResult<T[]>) => void; onChange: (value: ScrapeResult<T[]>) => void;
newObjects?: T[]; newObjects?: T[];
onCreateNew?: (value: T) => void; onCreateNew?: (value: T) => void;
onLinkExisting?: (value: T) => void;
renderObjects: ( renderObjects: (
result: ScrapeResult<T[]>, result: ScrapeResult<T[]>,
isNew?: boolean, isNew?: boolean,
@ -170,6 +186,7 @@ export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
onChange, onChange,
newObjects, newObjects,
onCreateNew, onCreateNew,
onLinkExisting,
renderObjects, renderObjects,
getName, getName,
} = props; } = props;
@ -189,6 +206,7 @@ export const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {
<NewScrapedObjects <NewScrapedObjects
newValues={newObjects ?? []} newValues={newObjects ?? []}
onCreateNew={onCreateNew} onCreateNew={onCreateNew}
onLinkExisting={onLinkExisting}
getName={getName} getName={getName}
/> />
) : undefined ) : undefined
@ -212,6 +230,7 @@ export const ScrapedPerformersRow: React.FC<
newObjects, newObjects,
onCreateNew, onCreateNew,
ageFromDate, ageFromDate,
onLinkExisting,
}) => { }) => {
const performersCopy = useMemo(() => { const performersCopy = useMemo(() => {
return ( return (
@ -268,13 +287,22 @@ export const ScrapedPerformersRow: React.FC<
newObjects={performersCopy} newObjects={performersCopy}
onCreateNew={onCreateNew} onCreateNew={onCreateNew}
getName={(value) => value.name ?? ""} getName={(value) => value.name ?? ""}
onLinkExisting={onLinkExisting}
/> />
); );
}; };
export const ScrapedGroupsRow: React.FC< export const ScrapedGroupsRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedGroup> IScrapedObjectRowImpl<GQL.ScrapedGroup>
> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { > = ({
title,
field,
result,
onChange,
newObjects,
onCreateNew,
onLinkExisting,
}) => {
const groupsCopy = useMemo(() => { const groupsCopy = useMemo(() => {
return ( return (
newObjects?.map((p) => { newObjects?.map((p) => {
@ -329,13 +357,22 @@ export const ScrapedGroupsRow: React.FC<
newObjects={groupsCopy} newObjects={groupsCopy}
onCreateNew={onCreateNew} onCreateNew={onCreateNew}
getName={(value) => value.name ?? ""} getName={(value) => value.name ?? ""}
onLinkExisting={onLinkExisting}
/> />
); );
}; };
export const ScrapedTagsRow: React.FC< export const ScrapedTagsRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedTag> IScrapedObjectRowImpl<GQL.ScrapedTag>
> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { > = ({
title,
field,
result,
onChange,
newObjects,
onCreateNew,
onLinkExisting,
}) => {
function renderScrapedTags( function renderScrapedTags(
scrapeResult: ScrapeResult<GQL.ScrapedTag[]>, scrapeResult: ScrapeResult<GQL.ScrapedTag[]>,
isNew?: boolean, isNew?: boolean,
@ -375,6 +412,7 @@ export const ScrapedTagsRow: React.FC<
onChange={onChange} onChange={onChange}
newObjects={newObjects} newObjects={newObjects}
onCreateNew={onCreateNew} onCreateNew={onCreateNew}
onLinkExisting={onLinkExisting}
getName={getObjectName} getName={getObjectName}
/> />
); );

View File

@ -183,12 +183,40 @@ export function useCreateScrapedGroup(
return useCreateObject("group", createNewGroup); 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( export function useCreateScrapedTag(
props: IUseCreateNewObjectProps<GQL.ScrapedTag> props: IUseCreateNewObjectProps<GQL.ScrapedTag>
) { ) {
const [createTag] = useTagCreate(); const [createTag] = useTagCreate();
const linkTag = useLinkScrapedTag(props);
const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;
async function createNewTag(toCreate: GQL.ScrapedTag) { async function createNewTag(toCreate: GQL.ScrapedTag) {
const input: GQL.TagCreateInput = { const input: GQL.TagCreateInput = {
@ -208,25 +236,12 @@ export function useCreateScrapedTag(
variables: { input }, variables: { input },
}); });
const newValue = [...(scrapeResult.newValue ?? [])];
if (result.data?.tagCreate) if (result.data?.tagCreate)
newValue.push({ linkTag(
stored_id: result.data.tagCreate.id, result.data.tagCreate.id,
name: result.data.tagCreate.name, result.data.tagCreate.name,
}); toCreate.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);
} }
return useCreateObject("tag", createNewTag); return useCreateObject("tag", createNewTag);

View File

@ -4,8 +4,11 @@ import * as GQL from "src/core/generated-graphql";
import { ObjectListScrapeResult } from "./scrapeResult"; import { ObjectListScrapeResult } from "./scrapeResult";
import { sortStoredIdObjects } from "src/utils/data"; import { sortStoredIdObjects } from "src/utils/data";
import { Tag } from "src/components/Tags/TagSelect"; import { Tag } from "src/components/Tags/TagSelect";
import { useCreateScrapedTag } from "./createObjects"; import { useCreateScrapedTag, useLinkScrapedTag } from "./createObjects";
import { ScrapedTagsRow } from "./ScrapedObjectsRow"; 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( export function useScrapedTags(
existingTags: Tag[], existingTags: Tag[],
@ -13,6 +16,8 @@ export function useScrapedTags(
endpoint?: string endpoint?: string
) { ) {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast();
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>( const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
new ObjectListScrapeResult<GQL.ScrapedTag>( new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects( sortStoredIdObjects(
@ -28,6 +33,7 @@ export function useScrapedTags(
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>( const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
scrapedTags?.filter((t) => !t.stored_id) ?? [] scrapedTags?.filter((t) => !t.stored_id) ?? []
); );
const [linkedTag, setLinkedTag] = useState<GQL.ScrapedTag | null>(null);
const createNewTag = useCreateScrapedTag({ const createNewTag = useCreateScrapedTag({
scrapeResult: tags, scrapeResult: tags,
@ -37,6 +43,79 @@ export function useScrapedTags(
endpoint, 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 = ( const scrapedTagsRow = (
<ScrapedTagsRow <ScrapedTagsRow
field="tags" field="tags"
@ -45,12 +124,14 @@ export function useScrapedTags(
onChange={(value) => setTags(value)} onChange={(value) => setTags(value)}
newObjects={newTags} newObjects={newTags}
onCreateNew={createNewTag} onCreateNew={createNewTag}
onLinkExisting={(l) => setLinkedTag(l)}
/> />
); );
return { return {
tags, tags,
newTags, newTags,
linkDialog,
scrapedTagsRow, 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, useStudioCreate,
useStudioUpdate, useStudioUpdate,
useTagCreate, useTagCreate,
useTagUpdate,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { useConfigurationContext } from "src/hooks/Config"; import { useConfigurationContext } from "src/hooks/Config";
@ -55,6 +56,10 @@ export interface ITaggerContextState {
) => Promise<string | undefined>; ) => Promise<string | undefined>;
updateStudio: (studio: GQL.StudioUpdateInput) => Promise<void>; updateStudio: (studio: GQL.StudioUpdateInput) => Promise<void>;
linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise<void>; linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise<void>;
updateTag: (
tag: GQL.ScrapedTag,
updateInput: GQL.TagUpdateInput
) => Promise<void>;
resolveScene: ( resolveScene: (
sceneID: string, sceneID: string,
index: number, index: number,
@ -92,6 +97,7 @@ export const TaggerStateContext = React.createContext<ITaggerContextState>({
createNewStudio: dummyValFn, createNewStudio: dummyValFn,
updateStudio: dummyFn, updateStudio: dummyFn,
linkStudio: dummyFn, linkStudio: dummyFn,
updateTag: dummyFn,
resolveScene: dummyFn, resolveScene: dummyFn,
submitFingerprints: dummyFn, submitFingerprints: dummyFn,
pendingFingerprints: [], pendingFingerprints: [],
@ -129,6 +135,7 @@ export const TaggerContext: React.FC = ({ children }) => {
const [createStudio] = useStudioCreate(); const [createStudio] = useStudioCreate();
const [updateStudio] = useStudioUpdate(); const [updateStudio] = useStudioUpdate();
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
const [updateTag] = useTagUpdate();
useEffect(() => { useEffect(() => {
if (!stashConfig || !Scrapers.data) { 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 ( return (
<TaggerStateContext.Provider <TaggerStateContext.Provider
value={{ value={{
@ -884,6 +933,7 @@ export const TaggerContext: React.FC = ({ children }) => {
createNewStudio, createNewStudio,
updateStudio: updateExistingStudio, updateStudio: updateExistingStudio,
linkStudio, linkStudio,
updateTag: updateExistingTag,
resolveScene, resolveScene,
saveScene, saveScene,
submitFingerprints, submitFingerprints,

View File

@ -3,10 +3,7 @@ import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql"; 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 { OptionalField } from "../IncludeButton";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { import {
Performer, Performer,
PerformerSelect, PerformerSelect,
@ -14,6 +11,7 @@ import {
import { getStashboxBase } from "src/utils/stashbox"; import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "src/components/Shared/ExternalLink"; import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LinkButton } from "../LinkButton";
const PerformerLink: React.FC<{ const PerformerLink: React.FC<{
performer: GQL.ScrapedPerformer | Performer; 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 selectedSource = !selectedID ? "skip" : "existing";
const safeBuildPerformerScraperLink = (id: string | null | undefined) => { const safeBuildPerformerScraperLink = (id: string | null | undefined) => {
@ -199,7 +182,9 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
isClearable={false} isClearable={false}
ageFromDate={ageFromDate} ageFromDate={ageFromDate}
/> />
{maybeRenderLinkButton()} {endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}
</ButtonGroup> </ButtonGroup>
</div> </div>
); );

View File

@ -7,6 +7,7 @@ import { blobToBase64 } from "base64-blob";
import { distance } from "src/utils/hamming"; import { distance } from "src/utils/hamming";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { import {
faLink,
faPlus, faPlus,
faTriangleExclamation, faTriangleExclamation,
faXmark, faXmark,
@ -232,6 +233,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
createNewStudio, createNewStudio,
updateStudio, updateStudio,
linkStudio, linkStudio,
updateTag,
resolveScene, resolveScene,
currentSource, currentSource,
saveScene, saveScene,
@ -248,9 +250,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
[scene, performerGenders] [scene, performerGenders]
); );
const { createPerformerModal, createStudioModal } = React.useContext( const { createPerformerModal, createStudioModal, createTagModal } =
SceneTaggerModalsState React.useContext(SceneTaggerModalsState);
);
const getInitialTags = useCallback(() => { const getInitialTags = useCallback(() => {
const stashSceneTags = stashScene.tags.map((t) => t.id); 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( async function studioModalCallback(
studio: GQL.ScrapedStudio, studio: GQL.ScrapedStudio,
toCreate?: GQL.StudioCreateInput, toCreate?: GQL.StudioCreateInput,
@ -711,26 +753,6 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
</div> </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() { function maybeRenderTagsField() {
if (!config.setTags) return; if (!config.setTags) return;
@ -764,9 +786,24 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
}} }}
> >
{t.name} {t.name}
<Button className="minimal ml-2"> <Button
className="minimal ml-2"
title={intl.formatMessage({ id: "actions.create" })}
>
<Icon className="fa-fw" icon={faPlus} /> <Icon className="fa-fw" icon={faPlus} />
</Button> </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> </Badge>
))} ))}
</div> </div>

View File

@ -3,16 +3,14 @@ import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import cx from "classnames"; 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 { StudioSelect, SelectObject } from "src/components/Shared/Select";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { OptionalField } from "../IncludeButton"; import { OptionalField } from "../IncludeButton";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { getStashboxBase } from "src/utils/stashbox"; import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "src/components/Shared/ExternalLink"; import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LinkButton } from "../LinkButton";
const StudioLink: React.FC<{ const StudioLink: React.FC<{
studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; 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 selectedSource = !selectedID ? "skip" : "existing";
const safeBuildStudioScraperLink = (id: string | null | undefined) => { const safeBuildStudioScraperLink = (id: string | null | undefined) => {
@ -169,7 +152,9 @@ const StudioResult: React.FC<IStudioResultProps> = ({
})} })}
isClearable={false} isClearable={false}
/> />
{maybeRenderLinkButton()} {endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}
</ButtonGroup> </ButtonGroup>
</div> </div>
); );

View File

@ -6,12 +6,17 @@ import PerformerModal from "../PerformerModal";
import { TaggerStateContext } from "../context"; import { TaggerStateContext } from "../context";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { faTags } from "@fortawesome/free-solid-svg-icons"; import { faTags } from "@fortawesome/free-solid-svg-icons";
import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog";
type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void;
type StudioModalCallback = ( type StudioModalCallback = (
toCreate?: GQL.StudioCreateInput, toCreate?: GQL.StudioCreateInput,
parentInput?: GQL.StudioCreateInput parentInput?: GQL.StudioCreateInput
) => void; ) => void;
type TagModalCallback = (result: {
create?: GQL.TagCreateInput;
update?: GQL.TagUpdateInput;
}) => void;
export interface ISceneTaggerModalsContextState { export interface ISceneTaggerModalsContextState {
createPerformerModal: ( createPerformerModal: (
@ -22,12 +27,14 @@ export interface ISceneTaggerModalsContextState {
studio: GQL.ScrapedSceneStudioDataFragment, studio: GQL.ScrapedSceneStudioDataFragment,
callback: StudioModalCallback callback: StudioModalCallback
) => void; ) => void;
createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void;
} }
export const SceneTaggerModalsState = export const SceneTaggerModalsState =
React.createContext<ISceneTaggerModalsContextState>({ React.createContext<ISceneTaggerModalsContextState>({
createPerformerModal: () => {}, createPerformerModal: () => {},
createStudioModal: () => {}, createStudioModal: () => {},
createTagModal: () => {},
}); });
export const SceneTaggerModals: React.FC = ({ children }) => { export const SceneTaggerModals: React.FC = ({ children }) => {
@ -47,6 +54,15 @@ export const SceneTaggerModals: React.FC = ({ children }) => {
StudioModalCallback | undefined 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(); const intl = useIntl();
function handlePerformerSave(toCreate: GQL.PerformerCreateInput) { function handlePerformerSave(toCreate: GQL.PerformerCreateInput) {
@ -106,11 +122,28 @@ export const SceneTaggerModals: React.FC = ({ children }) => {
setStudioCallback(() => callback); 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; const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined;
return ( return (
<SceneTaggerModalsState.Provider <SceneTaggerModalsState.Provider
value={{ createPerformerModal, createStudioModal }} value={{ createPerformerModal, createStudioModal, createTagModal }}
> >
{performerToCreate && ( {performerToCreate && (
<PerformerModal <PerformerModal
@ -141,6 +174,13 @@ export const SceneTaggerModals: React.FC = ({ children }) => {
endpoint={endpoint} endpoint={endpoint}
/> />
)} )}
{tagToCreate && (
<CreateLinkTagDialog
tag={tagToCreate}
onClose={handleTagSave}
endpoint={endpoint}
/>
)}
{children} {children}
</SceneTaggerModalsState.Provider> </SceneTaggerModalsState.Provider>
); );

View File

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

View File

@ -150,3 +150,21 @@ export const useToast = () => {
[addToast] [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_chapters": "Create Chapter",
"create_entity": "Create {entityType}", "create_entity": "Create {entityType}",
"create_marker": "Create Marker", "create_marker": "Create Marker",
"create_new": "Create new",
"create_parent_studio": "Create parent studio", "create_parent_studio": "Create parent studio",
"created_entity": "Created {entity_type}: {entity_name}", "created_entity": "Created {entity_type}: {entity_name}",
"customise": "Customise", "customise": "Customise",
@ -225,7 +226,10 @@
"phash_matches": "{count} PHashes match", "phash_matches": "{count} PHashes match",
"unnamed": "Unnamed" "unnamed": "Unnamed"
}, },
"verb_add_as_alias": "Add scraped name as alias",
"verb_link_existing": "Link to existing",
"verb_match_fp": "Match Fingerprints", "verb_match_fp": "Match Fingerprints",
"verb_match_tag": "Match Tag",
"verb_matched": "Matched", "verb_matched": "Matched",
"verb_scrape_all": "Scrape All", "verb_scrape_all": "Scrape All",
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",

View File

@ -42,17 +42,28 @@ export const separateNamesAndStashIds = (
*/ */
export const addUpdateStashID = ( export const addUpdateStashID = (
existingStashIDs: GQL.StashIdInput[], existingStashIDs: GQL.StashIdInput[],
newItem: GQL.StashIdInput newItem: GQL.StashIdInput,
allowMultiple: boolean = false
): GQL.StashIdInput[] => { ): GQL.StashIdInput[] => {
const existingIndex = existingStashIDs.findIndex( const existingIndex = existingStashIDs.findIndex(
(s) => s.endpoint === newItem.endpoint (s) => s.endpoint === newItem.endpoint
); );
if (existingIndex >= 0) { if (!allowMultiple && existingIndex >= 0) {
const newStashIDs = [...existingStashIDs]; const newStashIDs = [...existingStashIDs];
newStashIDs[existingIndex] = newItem; newStashIDs[existingIndex] = newItem;
return newStashIDs; 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]; return [...existingStashIDs, newItem];
}; };