mirror of
https://github.com/stashapp/stash.git
synced 2025-12-10 00:08:04 -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) ?? []
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 { 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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user