mirror of
https://github.com/stashapp/stash.git
synced 2025-12-10 10:13:54 -06:00
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:
parent
945d679158
commit
7fded66bfa
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
25
ui/v2.5/src/components/Tagger/LinkButton.tsx
Normal file
25
ui/v2.5/src/components/Tagger/LinkButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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}}",
|
||||||
|
|||||||
@ -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];
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user