stash/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx
WithoutPants 2b699fcf95
Default create missing to true in Identify (#4873)
* Default create missing to true in Identify
* Update manual
2024-05-30 13:12:07 +10:00

481 lines
12 KiB
TypeScript

import React, { useState, useEffect, useMemo } from "react";
import { Button, Form } from "react-bootstrap";
import {
mutateMetadataIdentify,
useConfiguration,
useConfigureDefaults,
useListSceneScrapers,
} from "src/core/StashService";
import { Icon } from "src/components/Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal";
import { OperationButton } from "src/components/Shared/OperationButton";
import { useToast } from "src/hooks/Toast";
import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl";
import { withoutTypename } from "src/utils/data";
import {
SCRAPER_PREFIX,
STASH_BOX_PREFIX,
} from "src/components/Tagger/constants";
import { DirectorySelectionDialog } from "src/components/Settings/Tasks/DirectorySelectionDialog";
import { Manual } from "src/components/Help/Manual";
import { IScraperSource } from "./constants";
import { OptionsEditor } from "./Options";
import { SourcesEditor, SourcesList } from "./Sources";
import {
faCogs,
faFolderOpen,
faQuestionCircle,
} from "@fortawesome/free-solid-svg-icons";
const autoTagScraperID = "builtin_autotag";
interface IIdentifyDialogProps {
selectedIds?: string[];
onClose: () => void;
}
export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
selectedIds,
onClose,
}) => {
function getDefaultOptions(): GQL.IdentifyMetadataOptionsInput {
return {
fieldOptions: [
{
field: "title",
strategy: GQL.IdentifyFieldStrategy.Overwrite,
},
{
field: "studio",
strategy: GQL.IdentifyFieldStrategy.Merge,
createMissing: true,
},
{
field: "performers",
strategy: GQL.IdentifyFieldStrategy.Merge,
createMissing: true,
},
{
field: "tags",
strategy: GQL.IdentifyFieldStrategy.Merge,
createMissing: true,
},
],
includeMalePerformers: true,
setCoverImage: true,
setOrganized: false,
skipMultipleMatches: true,
skipMultipleMatchTag: undefined,
skipSingleNamePerformers: true,
skipSingleNamePerformerTag: undefined,
};
}
const [configureDefaults] = useConfigureDefaults();
const [options, setOptions] = useState<GQL.IdentifyMetadataOptionsInput>(
getDefaultOptions()
);
const [sources, setSources] = useState<IScraperSource[]>([]);
const [editingSource, setEditingSource] = useState<
IScraperSource | undefined
>();
const [paths, setPaths] = useState<string[]>([]);
const [showManual, setShowManual] = useState(false);
const [settingPaths, setSettingPaths] = useState(false);
const [animation, setAnimation] = useState(true);
const [editingField, setEditingField] = useState(false);
const [savingDefaults, setSavingDefaults] = useState(false);
const intl = useIntl();
const Toast = useToast();
const { data: configData, error: configError } = useConfiguration();
const { data: scraperData, error: scraperError } = useListSceneScrapers();
const allSources = useMemo(() => {
if (!configData || !scraperData) return;
const ret: IScraperSource[] = [];
ret.push(
...configData.configuration.general.stashBoxes.map((b, i) => {
return {
id: `${STASH_BOX_PREFIX}${i}`,
displayName: `stash-box: ${b.name}`,
stash_box_endpoint: b.endpoint,
};
})
);
const scrapers = scraperData.listScrapers;
const fragmentScrapers = scrapers.filter((s) =>
s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment)
);
ret.push(
...fragmentScrapers.map((s) => {
return {
id: `${SCRAPER_PREFIX}${s.id}`,
displayName: s.name,
scraper_id: s.id,
};
})
);
return ret;
}, [configData, scraperData]);
const selectionStatus = useMemo(() => {
if (selectedIds) {
return (
<Form.Group id="selected-identify-ids">
<FormattedMessage
id="config.tasks.identify.identifying_scenes"
values={{
num: selectedIds.length,
scene: intl.formatMessage(
{
id: "countables.scenes",
},
{
count: selectedIds.length,
}
),
}}
/>
.
</Form.Group>
);
}
const message = paths.length ? (
<div>
<FormattedMessage id="config.tasks.identify.identifying_from_paths" />:
<ul>
{paths.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
) : (
<span>
<FormattedMessage
id="config.tasks.identify.identifying_scenes"
values={{
num: intl.formatMessage({ id: "all" }),
scene: intl.formatMessage(
{
id: "countables.scenes",
},
{
count: 0,
}
),
}}
/>
.
</span>
);
function onClick() {
setAnimation(false);
setSettingPaths(true);
}
return (
<Form.Group className="dialog-selected-folders">
<div>
{message}
<div>
<Button
title={intl.formatMessage({ id: "actions.select_folders" })}
onClick={() => onClick()}
>
<Icon icon={faFolderOpen} />
</Button>
</div>
</div>
</Form.Group>
);
}, [selectedIds, intl, paths]);
useEffect(() => {
if (!configData || !allSources) return;
const { identify: identifyDefaults } = configData.configuration.defaults;
if (identifyDefaults) {
const mappedSources = identifyDefaults.sources
.map((s) => {
const found = allSources.find(
(ss) =>
ss.scraper_id === s.source.scraper_id ||
ss.stash_box_endpoint === s.source.stash_box_endpoint
);
if (!found) return;
const ret: IScraperSource = {
...found,
};
if (s.options) {
const sourceOptions = withoutTypename(s.options);
sourceOptions.fieldOptions =
sourceOptions.fieldOptions?.map(withoutTypename);
ret.options = sourceOptions;
}
return ret;
})
.filter((s) => s) as IScraperSource[];
setSources(mappedSources);
if (identifyDefaults.options) {
const defaultOptions = withoutTypename(identifyDefaults.options);
defaultOptions.fieldOptions =
defaultOptions.fieldOptions?.map(withoutTypename);
setOptions(defaultOptions);
}
} else {
// default to first stash-box instance only
const stashBox = allSources.find((s) => s.stash_box_endpoint);
// add auto-tag as well
const autoTag = allSources.find(
(s) => s.id === `${SCRAPER_PREFIX}${autoTagScraperID}`
);
const newSources: IScraperSource[] = [];
if (stashBox) {
newSources.push(stashBox);
}
// sanity check - this should always be true
if (autoTag) {
// don't set organised by default
const autoTagCopy = { ...autoTag };
autoTagCopy.options = {
setOrganized: false,
skipMultipleMatches: true,
skipSingleNamePerformers: true,
};
newSources.push(autoTagCopy);
}
setSources(newSources);
}
}, [allSources, configData]);
if (configError || scraperError)
return <div>{configError ?? scraperError}</div>;
if (!allSources || !configData) return <div />;
function makeIdentifyInput(): GQL.IdentifyMetadataInput {
return {
sources: sources.map((s) => {
return {
source: {
scraper_id: s.scraper_id,
stash_box_endpoint: s.stash_box_endpoint,
},
options: s.options,
};
}),
options,
sceneIDs: selectedIds,
paths,
};
}
function makeDefaultIdentifyInput() {
const ret = makeIdentifyInput();
const { sceneIDs, paths: _paths, ...withoutSpecifics } = ret;
return withoutSpecifics;
}
async function onIdentify() {
try {
await mutateMetadataIdentify(makeIdentifyInput());
Toast.success(
intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.identify" }) }
)
);
} catch (e) {
Toast.error(e);
} finally {
onClose();
}
}
function getAvailableSources() {
// only include scrapers not already present
return !editingSource?.id === undefined
? []
: allSources?.filter((s) => {
return !sources.some((ss) => ss.id === s.id);
}) ?? [];
}
function onEditSource(s?: IScraperSource) {
setAnimation(false);
// if undefined, then set a dummy source to create a new one
if (!s) {
setEditingSource(getAvailableSources()[0]);
} else {
setEditingSource(s);
}
}
function onShowManual() {
setAnimation(false);
setShowManual(true);
}
function isNewSource() {
return !!editingSource && !sources.includes(editingSource);
}
function onSaveSource(s?: IScraperSource) {
if (s) {
let found = false;
const newSources = sources.map((ss) => {
if (ss.id === s.id) {
found = true;
return s;
}
return ss;
});
if (!found) {
newSources.push(s);
}
setSources(newSources);
}
setEditingSource(undefined);
}
async function setAsDefault() {
try {
setSavingDefaults(true);
await configureDefaults({
variables: {
input: {
identify: makeDefaultIdentifyInput(),
},
},
});
Toast.success(
intl.formatMessage(
{ id: "config.tasks.defaults_set" },
{ action: intl.formatMessage({ id: "actions.identify" }) }
)
);
} catch (e) {
Toast.error(e);
} finally {
setSavingDefaults(false);
}
}
if (editingSource) {
return (
<SourcesEditor
availableSources={getAvailableSources()}
source={editingSource}
saveSource={onSaveSource}
isNew={isNewSource()}
defaultOptions={options}
/>
);
}
if (settingPaths) {
return (
<DirectorySelectionDialog
animation={false}
allowEmpty
initialPaths={paths}
onClose={(p) => {
if (p) {
setPaths(p);
}
setSettingPaths(false);
}}
/>
);
}
if (showManual) {
return (
<Manual
animation={false}
show
onClose={() => setShowManual(false)}
defaultActiveTab="Identify.md"
/>
);
}
return (
<ModalComponent
modalProps={{ animation, size: "lg" }}
show
icon={faCogs}
header={intl.formatMessage({ id: "actions.identify" })}
accept={{
onClick: onIdentify,
text: intl.formatMessage({ id: "actions.identify" }),
}}
cancel={{
onClick: () => onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
disabled={editingField || savingDefaults || sources.length === 0}
footerButtons={
<OperationButton
variant="secondary"
disabled={editingField || savingDefaults}
operation={setAsDefault}
>
<FormattedMessage id="actions.set_as_default" />
</OperationButton>
}
leftFooterButtons={
<Button
title="Help"
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon={faQuestionCircle} />
</Button>
}
>
<Form>
{selectionStatus}
<SourcesList
sources={sources}
setSources={(s) => setSources(s)}
editSource={onEditSource}
canAdd={sources.length < allSources.length}
/>
<OptionsEditor
options={options}
setOptions={(o) => setOptions(o)}
setEditingField={(v) => setEditingField(v)}
/>
</Form>
</ModalComponent>
);
};
export default IdentifyDialog;