mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Refactor scrape dialog (#4069)
* Fix performer select showing blank values after scrape * Move and separate scrape dialog * Separate row components from scene scrape dialog * Refactor object creation * Refactor gallery scrape dialog
This commit is contained in:
518
ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx
Normal file
518
ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
InputGroup,
|
||||
Button,
|
||||
FormControl,
|
||||
Badge,
|
||||
} from "react-bootstrap";
|
||||
import { CollapseButton } from "../CollapseButton";
|
||||
import { Icon } from "../Icon";
|
||||
import { ModalComponent } from "../Modal";
|
||||
import clone from "lodash-es/clone";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import {
|
||||
faCheck,
|
||||
faPencilAlt,
|
||||
faPlus,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { getCountryByISO } from "src/utils/country";
|
||||
import { CountrySelect } from "../CountrySelect";
|
||||
import { StringListInput } from "../StringListInput";
|
||||
import { ImageSelector } from "../ImageSelector";
|
||||
import { ScrapeResult } from "./scrapeResult";
|
||||
|
||||
export interface IHasName {
|
||||
name: string | undefined;
|
||||
}
|
||||
|
||||
interface IScrapedFieldProps<T> {
|
||||
result: ScrapeResult<T>;
|
||||
}
|
||||
|
||||
interface IScrapedRowProps<T, V extends IHasName>
|
||||
extends IScrapedFieldProps<T> {
|
||||
className?: string;
|
||||
title: string;
|
||||
renderOriginalField: (result: ScrapeResult<T>) => JSX.Element | undefined;
|
||||
renderNewField: (result: ScrapeResult<T>) => JSX.Element | undefined;
|
||||
onChange: (value: ScrapeResult<T>) => void;
|
||||
newValues?: V[];
|
||||
onCreateNew?: (index: number) => void;
|
||||
}
|
||||
|
||||
function renderButtonIcon(selected: boolean) {
|
||||
const className = selected ? "text-success" : "text-muted";
|
||||
|
||||
return (
|
||||
<Icon
|
||||
className={`fa-fw ${className}`}
|
||||
icon={selected ? faCheck : faTimes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ScrapeDialogRow = <T, V extends IHasName>(
|
||||
props: IScrapedRowProps<T, V>
|
||||
) => {
|
||||
function handleSelectClick(isNew: boolean) {
|
||||
const ret = clone(props.result);
|
||||
ret.useNewValue = isNew;
|
||||
props.onChange(ret);
|
||||
}
|
||||
|
||||
function hasNewValues() {
|
||||
return props.newValues && props.newValues.length > 0 && props.onCreateNew;
|
||||
}
|
||||
|
||||
if (!props.result.scraped && !hasNewValues()) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function renderNewValues() {
|
||||
if (!hasNewValues()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ret = (
|
||||
<>
|
||||
{props.newValues!.map((t, i) => (
|
||||
<Badge
|
||||
className="tag-item"
|
||||
variant="secondary"
|
||||
key={t.name}
|
||||
onClick={() => props.onCreateNew!(i)}
|
||||
>
|
||||
{t.name}
|
||||
<Button className="minimal ml-2">
|
||||
<Icon className="fa-fw" icon={faPlus} />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const minCollapseLength = 10;
|
||||
|
||||
if (props.newValues!.length >= minCollapseLength) {
|
||||
return (
|
||||
<CollapseButton text={`Missing (${props.newValues!.length})`}>
|
||||
{ret}
|
||||
</CollapseButton>
|
||||
);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className={`px-3 pt-3 ${props.className ?? ""}`}>
|
||||
<Form.Label column lg="3">
|
||||
{props.title}
|
||||
</Form.Label>
|
||||
|
||||
<Col lg="9">
|
||||
<Row>
|
||||
<Col xs="6">
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend className="bg-secondary text-white border-secondary">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectClick(false)}
|
||||
>
|
||||
{renderButtonIcon(!props.result.useNewValue)}
|
||||
</Button>
|
||||
</InputGroup.Prepend>
|
||||
{props.renderOriginalField(props.result)}
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs="6">
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectClick(true)}
|
||||
>
|
||||
{renderButtonIcon(props.result.useNewValue)}
|
||||
</Button>
|
||||
</InputGroup.Prepend>
|
||||
{props.renderNewField(props.result)}
|
||||
</InputGroup>
|
||||
{renderNewValues()}
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedInputGroupProps {
|
||||
isNew?: boolean;
|
||||
placeholder?: string;
|
||||
locked?: boolean;
|
||||
result: ScrapeResult<string>;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const ScrapedInputGroup: React.FC<IScrapedInputGroupProps> = (props) => {
|
||||
return (
|
||||
<FormControl
|
||||
placeholder={props.placeholder}
|
||||
value={props.isNew ? props.result.newValue : props.result.originalValue}
|
||||
readOnly={!props.isNew || props.locked}
|
||||
onChange={(e) => {
|
||||
if (props.isNew && props.onChange) {
|
||||
props.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="bg-secondary text-white border-secondary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedInputGroupRowProps {
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
result: ScrapeResult<string>;
|
||||
locked?: boolean;
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
}
|
||||
|
||||
export const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (
|
||||
props
|
||||
) => {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={props.title}
|
||||
result={props.result}
|
||||
renderOriginalField={() => (
|
||||
<ScrapedInputGroup
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<ScrapedInputGroup
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
isNew
|
||||
locked={props.locked}
|
||||
onChange={(value) =>
|
||||
props.onChange(props.result.cloneWithValue(value))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedStringListProps {
|
||||
isNew?: boolean;
|
||||
placeholder?: string;
|
||||
locked?: boolean;
|
||||
result: ScrapeResult<string[]>;
|
||||
onChange?: (value: string[]) => void;
|
||||
}
|
||||
|
||||
const ScrapedStringList: React.FC<IScrapedStringListProps> = (props) => {
|
||||
const value = props.isNew
|
||||
? props.result.newValue
|
||||
: props.result.originalValue;
|
||||
|
||||
return (
|
||||
<StringListInput
|
||||
value={value ?? []}
|
||||
setValue={(v) => {
|
||||
if (props.isNew && props.onChange) {
|
||||
props.onChange(v);
|
||||
}
|
||||
}}
|
||||
placeholder={props.placeholder}
|
||||
readOnly={!props.isNew || props.locked}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedStringListRowProps {
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
result: ScrapeResult<string[]>;
|
||||
locked?: boolean;
|
||||
onChange: (value: ScrapeResult<string[]>) => void;
|
||||
}
|
||||
|
||||
export const ScrapedStringListRow: React.FC<IScrapedStringListRowProps> = (
|
||||
props
|
||||
) => {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
className="string-list-row"
|
||||
title={props.title}
|
||||
result={props.result}
|
||||
renderOriginalField={() => (
|
||||
<ScrapedStringList
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<ScrapedStringList
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
isNew
|
||||
locked={props.locked}
|
||||
onChange={(value) =>
|
||||
props.onChange(props.result.cloneWithValue(value))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrapedTextArea: React.FC<IScrapedInputGroupProps> = (props) => {
|
||||
return (
|
||||
<FormControl
|
||||
as="textarea"
|
||||
placeholder={props.placeholder}
|
||||
value={props.isNew ? props.result.newValue : props.result.originalValue}
|
||||
readOnly={!props.isNew}
|
||||
onChange={(e) => {
|
||||
if (props.isNew && props.onChange) {
|
||||
props.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="bg-secondary text-white border-secondary scene-description"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrapedTextAreaRow: React.FC<IScrapedInputGroupRowProps> = (
|
||||
props
|
||||
) => {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={props.title}
|
||||
result={props.result}
|
||||
renderOriginalField={() => (
|
||||
<ScrapedTextArea
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<ScrapedTextArea
|
||||
placeholder={props.placeholder || props.title}
|
||||
result={props.result}
|
||||
isNew
|
||||
onChange={(value) =>
|
||||
props.onChange(props.result.cloneWithValue(value))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedImageProps {
|
||||
isNew?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
result: ScrapeResult<string>;
|
||||
}
|
||||
|
||||
const ScrapedImage: React.FC<IScrapedImageProps> = (props) => {
|
||||
const value = props.isNew
|
||||
? props.result.newValue
|
||||
: props.result.originalValue;
|
||||
|
||||
if (!value) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<img className={props.className} src={value} alt={props.placeholder} />
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedImageRowProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
result: ScrapeResult<string>;
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
}
|
||||
|
||||
export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={props.title}
|
||||
result={props.result}
|
||||
renderOriginalField={() => (
|
||||
<ScrapedImage
|
||||
result={props.result}
|
||||
className={props.className}
|
||||
placeholder={props.title}
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<ScrapedImage
|
||||
result={props.result}
|
||||
className={props.className}
|
||||
placeholder={props.title}
|
||||
isNew
|
||||
/>
|
||||
)}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedImagesRowProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
result: ScrapeResult<string>;
|
||||
images: string[];
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
}
|
||||
|
||||
export const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
|
||||
function onSetImageIndex(newIdx: number) {
|
||||
const ret = props.result.cloneWithValue(props.images[newIdx]);
|
||||
props.onChange(ret);
|
||||
setImageIndex(newIdx);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={props.title}
|
||||
result={props.result}
|
||||
renderOriginalField={() => (
|
||||
<ScrapedImage
|
||||
result={props.result}
|
||||
className={props.className}
|
||||
placeholder={props.title}
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<div>
|
||||
<ImageSelector
|
||||
imageClassName={props.className}
|
||||
images={props.images}
|
||||
imageIndex={imageIndex}
|
||||
setImageIndex={onSetImageIndex}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapeDialogProps {
|
||||
title: string;
|
||||
existingLabel?: string;
|
||||
scrapedLabel?: string;
|
||||
renderScrapeRows: () => JSX.Element;
|
||||
onClose: (apply?: boolean) => void;
|
||||
}
|
||||
|
||||
export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
|
||||
props: IScrapeDialogProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<ModalComponent
|
||||
show
|
||||
icon={faPencilAlt}
|
||||
header={props.title}
|
||||
accept={{
|
||||
onClick: () => {
|
||||
props.onClose(true);
|
||||
},
|
||||
text: intl.formatMessage({ id: "actions.apply" }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
modalProps={{ size: "lg", dialogClassName: "scrape-dialog" }}
|
||||
>
|
||||
<div className="dialog-container">
|
||||
<Form>
|
||||
<Row className="px-3 pt-3">
|
||||
<Col lg={{ span: 9, offset: 3 }}>
|
||||
<Row>
|
||||
<Form.Label column xs="6">
|
||||
{props.existingLabel ?? (
|
||||
<FormattedMessage id="dialogs.scrape_results_existing" />
|
||||
)}
|
||||
</Form.Label>
|
||||
<Form.Label column xs="6">
|
||||
{props.scrapedLabel ?? (
|
||||
<FormattedMessage id="dialogs.scrape_results_scraped" />
|
||||
)}
|
||||
</Form.Label>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{props.renderScrapeRows()}
|
||||
</Form>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedCountryRowProps {
|
||||
title: string;
|
||||
result: ScrapeResult<string>;
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
locked?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
|
||||
title,
|
||||
result,
|
||||
onChange,
|
||||
locked,
|
||||
locale,
|
||||
}) => (
|
||||
<ScrapeDialogRow
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => (
|
||||
<FormControl
|
||||
value={
|
||||
getCountryByISO(result.originalValue, locale) ?? result.originalValue
|
||||
}
|
||||
readOnly
|
||||
className="bg-secondary text-white border-secondary"
|
||||
/>
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<CountrySelect
|
||||
value={result.newValue}
|
||||
disabled={locked}
|
||||
onChange={(value) => {
|
||||
if (onChange) {
|
||||
onChange(result.cloneWithValue(value));
|
||||
}
|
||||
}}
|
||||
showFlag={false}
|
||||
isClearable={false}
|
||||
className="flex-grow-1"
|
||||
/>
|
||||
)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
269
ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx
Normal file
269
ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useMemo } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
MovieSelect,
|
||||
TagSelect,
|
||||
StudioSelect,
|
||||
} from "src/components/Shared/Select";
|
||||
import {
|
||||
ScrapeDialogRow,
|
||||
IHasName,
|
||||
} from "src/components/Shared/ScrapeDialog/ScrapeDialog";
|
||||
import { PerformerSelect } from "src/components/Performers/PerformerSelect";
|
||||
import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
|
||||
|
||||
interface IScrapedStudioRow {
|
||||
title: string;
|
||||
result: ScrapeResult<string>;
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
newStudio?: GQL.ScrapedStudio;
|
||||
onCreateNew?: (value: GQL.ScrapedStudio) => void;
|
||||
}
|
||||
|
||||
export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
|
||||
title,
|
||||
result,
|
||||
onChange,
|
||||
newStudio,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
function renderScrapedStudio(
|
||||
scrapeResult: ScrapeResult<string>,
|
||||
isNew?: boolean,
|
||||
onChangeFn?: (value: string) => void
|
||||
) {
|
||||
const resultValue = isNew
|
||||
? scrapeResult.newValue
|
||||
: scrapeResult.originalValue;
|
||||
const value = resultValue ? [resultValue] : [];
|
||||
|
||||
return (
|
||||
<StudioSelect
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChangeFn) {
|
||||
onChangeFn(items[0]?.id);
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedStudio(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedStudio(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
newValues={newStudio ? [newStudio] : undefined}
|
||||
onCreateNew={() => {
|
||||
if (onCreateNew && newStudio) onCreateNew(newStudio);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScrapedObjectsRow<T, R> {
|
||||
title: string;
|
||||
result: ScrapeResult<R[]>;
|
||||
onChange: (value: ScrapeResult<R[]>) => void;
|
||||
newObjects?: T[];
|
||||
onCreateNew?: (value: T) => void;
|
||||
renderObjects: (
|
||||
result: ScrapeResult<R[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: R[]) => void
|
||||
) => JSX.Element;
|
||||
}
|
||||
|
||||
export const ScrapedObjectsRow = <T extends IHasName, R>(
|
||||
props: IScrapedObjectsRow<T, R>
|
||||
) => {
|
||||
const { title, result, onChange, newObjects, onCreateNew, renderObjects } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => renderObjects(result)}
|
||||
renderNewField={() =>
|
||||
renderObjects(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
newValues={newObjects}
|
||||
onCreateNew={(i) => {
|
||||
if (onCreateNew) onCreateNew(newObjects![i]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type IScrapedObjectRowImpl<T, R> = Omit<
|
||||
IScrapedObjectsRow<T, R>,
|
||||
"renderObjects"
|
||||
>;
|
||||
|
||||
export const ScrapedPerformersRow: React.FC<
|
||||
IScrapedObjectRowImpl<GQL.ScrapedPerformer, GQL.ScrapedPerformer>
|
||||
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
|
||||
const performersCopy = useMemo(() => {
|
||||
return (
|
||||
newObjects?.map((p) => {
|
||||
const name: string = p.name ?? "";
|
||||
return { ...p, name };
|
||||
}) ?? []
|
||||
);
|
||||
}, [newObjects]);
|
||||
|
||||
function renderScrapedPerformers(
|
||||
scrapeResult: ScrapeResult<GQL.ScrapedPerformer[]>,
|
||||
isNew?: boolean,
|
||||
onChangeFn?: (value: GQL.ScrapedPerformer[]) => void
|
||||
) {
|
||||
const resultValue = isNew
|
||||
? scrapeResult.newValue
|
||||
: scrapeResult.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
const selectValue = value.map((p) => {
|
||||
const alias_list: string[] = [];
|
||||
return {
|
||||
id: p.stored_id ?? "",
|
||||
name: p.name ?? "",
|
||||
alias_list,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChangeFn) {
|
||||
onChangeFn(items);
|
||||
}
|
||||
}}
|
||||
values={selectValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PerformerType = GQL.ScrapedPerformer & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrapedObjectsRow<PerformerType, GQL.ScrapedPerformer>
|
||||
title={title}
|
||||
result={result}
|
||||
renderObjects={renderScrapedPerformers}
|
||||
onChange={onChange}
|
||||
newObjects={performersCopy}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrapedMoviesRow: React.FC<
|
||||
IScrapedObjectRowImpl<GQL.ScrapedMovie, string>
|
||||
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
|
||||
const moviesCopy = useMemo(() => {
|
||||
return (
|
||||
newObjects?.map((p) => {
|
||||
const name: string = p.name ?? "";
|
||||
return { ...p, name };
|
||||
}) ?? []
|
||||
);
|
||||
}, [newObjects]);
|
||||
|
||||
type MovieType = GQL.ScrapedMovie & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function renderScrapedMovies(
|
||||
scrapeResult: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChangeFn?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew
|
||||
? scrapeResult.newValue
|
||||
: scrapeResult.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
return (
|
||||
<MovieSelect
|
||||
isMulti
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChangeFn) {
|
||||
onChangeFn(items.map((i) => i.id));
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapedObjectsRow<MovieType, string>
|
||||
title={title}
|
||||
result={result}
|
||||
renderObjects={renderScrapedMovies}
|
||||
onChange={onChange}
|
||||
newObjects={moviesCopy}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrapedTagsRow: React.FC<
|
||||
IScrapedObjectRowImpl<GQL.ScrapedTag, string>
|
||||
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
|
||||
function renderScrapedTags(
|
||||
scrapeResult: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChangeFn?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew
|
||||
? scrapeResult.newValue
|
||||
: scrapeResult.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
return (
|
||||
<TagSelect
|
||||
isMulti
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChangeFn) {
|
||||
onChangeFn(items.map((i) => i.id));
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapedObjectsRow<GQL.ScrapedTag, string>
|
||||
title={title}
|
||||
result={result}
|
||||
renderObjects={renderScrapedTags}
|
||||
onChange={onChange}
|
||||
newObjects={newObjects}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
};
|
||||
192
ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts
Normal file
192
ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
useMovieCreate,
|
||||
usePerformerCreate,
|
||||
useStudioCreate,
|
||||
useTagCreate,
|
||||
} from "src/core/StashService";
|
||||
import { ScrapeResult } from "./scrapeResult";
|
||||
import { useIntl } from "react-intl";
|
||||
import { scrapedPerformerToCreateInput } from "src/core/performers";
|
||||
import { scrapedMovieToCreateInput } from "src/core/movies";
|
||||
|
||||
function useCreateObject<T>(
|
||||
entityTypeID: string,
|
||||
createFunc: (o: T) => Promise<void>
|
||||
) {
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
async function createNewObject(o: T) {
|
||||
try {
|
||||
await createFunc(o);
|
||||
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.created_entity" },
|
||||
{
|
||||
entity: intl
|
||||
.formatMessage({ id: entityTypeID })
|
||||
.toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return createNewObject;
|
||||
}
|
||||
|
||||
interface IUseCreateNewStudioProps {
|
||||
scrapeResult: ScrapeResult<string>;
|
||||
setScrapeResult: (scrapeResult: ScrapeResult<string>) => void;
|
||||
setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void;
|
||||
}
|
||||
|
||||
export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) {
|
||||
const [createStudio] = useStudioCreate();
|
||||
|
||||
const { scrapeResult, setScrapeResult, setNewObject } = props;
|
||||
|
||||
async function createNewStudio(toCreate: GQL.ScrapedStudio) {
|
||||
const result = await createStudio({
|
||||
variables: {
|
||||
input: {
|
||||
name: toCreate.name,
|
||||
url: toCreate.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// set the new studio as the value
|
||||
setScrapeResult(scrapeResult.cloneWithValue(result.data!.studioCreate!.id));
|
||||
setNewObject(undefined);
|
||||
}
|
||||
|
||||
return useCreateObject("studio", createNewStudio);
|
||||
}
|
||||
|
||||
interface IUseCreateNewPerformerProps {
|
||||
scrapeResult: ScrapeResult<GQL.ScrapedPerformer[]>;
|
||||
setScrapeResult: (scrapeResult: ScrapeResult<GQL.ScrapedPerformer[]>) => void;
|
||||
newObjects: GQL.ScrapedPerformer[];
|
||||
setNewObjects: (newObject: GQL.ScrapedPerformer[]) => void;
|
||||
}
|
||||
|
||||
export function useCreateScrapedPerformer(props: IUseCreateNewPerformerProps) {
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;
|
||||
|
||||
async function createNewPerformer(toCreate: GQL.ScrapedPerformer) {
|
||||
const input = scrapedPerformerToCreateInput(toCreate);
|
||||
|
||||
const result = await createPerformer({
|
||||
variables: { input },
|
||||
});
|
||||
|
||||
const newValue = [...(scrapeResult.newValue ?? [])];
|
||||
if (result.data?.performerCreate)
|
||||
newValue.push({
|
||||
stored_id: result.data.performerCreate.id,
|
||||
name: result.data.performerCreate.name,
|
||||
});
|
||||
|
||||
// add the new performer to the new performers value
|
||||
const performerClone = scrapeResult.cloneWithValue(newValue);
|
||||
setScrapeResult(performerClone);
|
||||
|
||||
// remove the performer from the list
|
||||
const newPerformersClone = newObjects.concat();
|
||||
const pIndex = newPerformersClone.findIndex(
|
||||
(p) => p.name === toCreate.name
|
||||
);
|
||||
if (pIndex === -1) throw new Error("Could not find performer to remove");
|
||||
|
||||
newPerformersClone.splice(pIndex, 1);
|
||||
|
||||
setNewObjects(newPerformersClone);
|
||||
}
|
||||
|
||||
return useCreateObject("performer", createNewPerformer);
|
||||
}
|
||||
|
||||
interface IUseCreateNewObjectIDListProps<
|
||||
T extends { name?: string | undefined | null }
|
||||
> {
|
||||
scrapeResult: ScrapeResult<string[]>;
|
||||
setScrapeResult: (scrapeResult: ScrapeResult<string[]>) => void;
|
||||
newObjects: T[];
|
||||
setNewObjects: (newObject: T[]) => void;
|
||||
}
|
||||
|
||||
function useCreateNewObjectIDList<
|
||||
T extends { name?: string | undefined | null }
|
||||
>(
|
||||
entityTypeID: string,
|
||||
props: IUseCreateNewObjectIDListProps<T>,
|
||||
createObject: (toCreate: T) => Promise<string>
|
||||
) {
|
||||
const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;
|
||||
|
||||
async function createNewObject(toCreate: T) {
|
||||
const newID = await createObject(toCreate);
|
||||
|
||||
// add the new object to the new objects value
|
||||
const newResult = scrapeResult.cloneWithValue(scrapeResult.newValue);
|
||||
if (!newResult.newValue) {
|
||||
newResult.newValue = [];
|
||||
}
|
||||
newResult.newValue.push(newID);
|
||||
setScrapeResult(newResult);
|
||||
|
||||
// remove the object from the list
|
||||
const newObjectsClone = newObjects.concat();
|
||||
const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name);
|
||||
if (pIndex === -1) throw new Error("Could not find object to remove");
|
||||
newObjectsClone.splice(pIndex, 1);
|
||||
|
||||
setNewObjects(newObjectsClone);
|
||||
}
|
||||
|
||||
return useCreateObject(entityTypeID, createNewObject);
|
||||
}
|
||||
|
||||
export function useCreateScrapedMovie(
|
||||
props: IUseCreateNewObjectIDListProps<GQL.ScrapedMovie>
|
||||
) {
|
||||
const [createMovie] = useMovieCreate();
|
||||
|
||||
async function createNewMovie(toCreate: GQL.ScrapedMovie) {
|
||||
const movieInput = scrapedMovieToCreateInput(toCreate);
|
||||
const result = await createMovie({
|
||||
variables: { input: movieInput },
|
||||
});
|
||||
|
||||
return result.data?.movieCreate?.id ?? "";
|
||||
}
|
||||
|
||||
return useCreateNewObjectIDList("movie", props, createNewMovie);
|
||||
}
|
||||
|
||||
export function useCreateScrapedTag(
|
||||
props: IUseCreateNewObjectIDListProps<GQL.ScrapedTag>
|
||||
) {
|
||||
const [createTag] = useTagCreate();
|
||||
|
||||
async function createNewTag(toCreate: GQL.ScrapedTag) {
|
||||
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
|
||||
const result = await createTag({
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
return result.data?.tagCreate?.id ?? "";
|
||||
}
|
||||
|
||||
return useCreateNewObjectIDList("tag", props, createNewTag);
|
||||
}
|
||||
71
ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts
Normal file
71
ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import clone from "lodash-es/clone";
|
||||
|
||||
export class ScrapeResult<T> {
|
||||
public newValue?: T;
|
||||
public originalValue?: T;
|
||||
public scraped: boolean = false;
|
||||
public useNewValue: boolean = false;
|
||||
|
||||
public constructor(
|
||||
originalValue?: T | null,
|
||||
newValue?: T | null,
|
||||
useNewValue?: boolean
|
||||
) {
|
||||
this.originalValue = originalValue ?? undefined;
|
||||
this.newValue = newValue ?? undefined;
|
||||
// NOTE: this means that zero values are treated as null
|
||||
// this is incorrect for numbers and booleans, but correct for strings
|
||||
const hasNewValue = !!this.newValue;
|
||||
|
||||
const valuesEqual = isEqual(originalValue, newValue);
|
||||
this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual);
|
||||
this.scraped = hasNewValue && !valuesEqual;
|
||||
}
|
||||
|
||||
public setOriginalValue(value?: T) {
|
||||
this.originalValue = value;
|
||||
this.newValue = value;
|
||||
}
|
||||
|
||||
public cloneWithValue(value?: T) {
|
||||
const ret = clone(this);
|
||||
|
||||
ret.newValue = value;
|
||||
ret.useNewValue = !isEqual(ret.newValue, ret.originalValue);
|
||||
|
||||
// #2691 - if we're setting the value, assume it should be treated as
|
||||
// scraped
|
||||
ret.scraped = true;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getNewValue() {
|
||||
if (this.useNewValue) {
|
||||
return this.newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// for types where !!value is a valid value (boolean and number)
|
||||
export class ZeroableScrapeResult<T> extends ScrapeResult<T> {
|
||||
public constructor(
|
||||
originalValue?: T | null,
|
||||
newValue?: T | null,
|
||||
useNewValue?: boolean
|
||||
) {
|
||||
super(originalValue, newValue, useNewValue);
|
||||
|
||||
const hasNewValue = this.newValue !== undefined;
|
||||
|
||||
const valuesEqual = isEqual(originalValue, newValue);
|
||||
this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual);
|
||||
this.scraped = hasNewValue && !valuesEqual;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function hasScrapedValues(values: ScrapeResult<any>[]) {
|
||||
return values.some((r) => r.scraped);
|
||||
}
|
||||
Reference in New Issue
Block a user