mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Identify task (#1839)
* Add identify task * Change type naming * Debounce folder select text input * Add generic slice comparison function
This commit is contained in:
338
ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx
Normal file
338
ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Form, Button, Table } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { multiValueSceneFields, SceneField, sceneFields } from "./constants";
|
||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||
|
||||
interface IFieldOptionsEditor {
|
||||
options: GQL.IdentifyFieldOptions | undefined;
|
||||
field: string;
|
||||
editField: () => void;
|
||||
editOptions: (o?: GQL.IdentifyFieldOptions | null) => void;
|
||||
editing: boolean;
|
||||
allowSetDefault: boolean;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
interface IFieldOptions {
|
||||
field: string;
|
||||
strategy: GQL.IdentifyFieldStrategy | undefined;
|
||||
createMissing?: GQL.Maybe<boolean> | undefined;
|
||||
}
|
||||
|
||||
const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
||||
options,
|
||||
field,
|
||||
editField,
|
||||
editOptions,
|
||||
editing,
|
||||
allowSetDefault,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [localOptions, setLocalOptions] = useState<IFieldOptions>();
|
||||
|
||||
const resetOptions = useCallback(() => {
|
||||
let toSet: IFieldOptions;
|
||||
if (!options) {
|
||||
// unset - use default values
|
||||
toSet = {
|
||||
field,
|
||||
strategy: undefined,
|
||||
createMissing: undefined,
|
||||
};
|
||||
} else {
|
||||
toSet = {
|
||||
field,
|
||||
strategy: options.strategy,
|
||||
createMissing: options.createMissing,
|
||||
};
|
||||
}
|
||||
setLocalOptions(toSet);
|
||||
}, [options, field]);
|
||||
|
||||
useEffect(() => {
|
||||
resetOptions();
|
||||
}, [resetOptions]);
|
||||
|
||||
function renderField() {
|
||||
return intl.formatMessage({ id: field });
|
||||
}
|
||||
|
||||
function renderStrategy() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const strategies = Object.entries(GQL.IdentifyFieldStrategy);
|
||||
let { strategy } = localOptions;
|
||||
if (strategy === undefined) {
|
||||
if (!allowSetDefault) {
|
||||
strategy = GQL.IdentifyFieldStrategy.Merge;
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
if (strategy === undefined) {
|
||||
return intl.formatMessage({ id: "use_default" });
|
||||
}
|
||||
|
||||
const f = strategies.find((s) => s[1] === strategy);
|
||||
return intl.formatMessage({
|
||||
id: `config.tasks.identify.field_strategies.${f![0].toLowerCase()}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!localOptions) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
{allowSetDefault ? (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id={`${field}-strategy-default`}
|
||||
checked={localOptions.strategy === undefined}
|
||||
onChange={() =>
|
||||
setLocalOptions({
|
||||
...localOptions,
|
||||
strategy: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!editing}
|
||||
label={intl.formatMessage({ id: "use_default" })}
|
||||
/>
|
||||
) : undefined}
|
||||
{strategies.map((f) => (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
key={f[0]}
|
||||
id={`${field}-strategy-${f[0]}`}
|
||||
checked={localOptions.strategy === f[1]}
|
||||
onChange={() =>
|
||||
setLocalOptions({
|
||||
...localOptions,
|
||||
strategy: f[1],
|
||||
})
|
||||
}
|
||||
disabled={!editing}
|
||||
label={intl.formatMessage({
|
||||
id: `config.tasks.identify.field_strategies.${f[0].toLowerCase()}`,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderCreateMissing() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
multiValueSceneFields.includes(localOptions.field as SceneField) &&
|
||||
localOptions.strategy !== GQL.IdentifyFieldStrategy.Ignore
|
||||
) {
|
||||
const value =
|
||||
localOptions.createMissing === null
|
||||
? undefined
|
||||
: localOptions.createMissing;
|
||||
|
||||
if (!editing) {
|
||||
if (value === undefined && allowSetDefault) {
|
||||
return intl.formatMessage({ id: "use_default" });
|
||||
}
|
||||
if (value) {
|
||||
return <Icon icon="check" className="text-success" />;
|
||||
}
|
||||
|
||||
return <Icon icon="times" className="text-danger" />;
|
||||
}
|
||||
|
||||
const defaultVal = defaultOptions?.fieldOptions?.find(
|
||||
(f) => f.field === localOptions.field
|
||||
)?.createMissing;
|
||||
|
||||
if (localOptions.strategy === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreeStateBoolean
|
||||
id="create-missing"
|
||||
disabled={!editing}
|
||||
allowUndefined={allowSetDefault}
|
||||
value={value}
|
||||
setValue={(v) =>
|
||||
setLocalOptions({ ...localOptions, createMissing: v })
|
||||
}
|
||||
defaultValue={defaultVal ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onEditOptions() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// send null if strategy is undefined
|
||||
if (localOptions.strategy === undefined) {
|
||||
editOptions(null);
|
||||
resetOptions();
|
||||
} else {
|
||||
let { createMissing } = localOptions;
|
||||
if (createMissing === undefined && !allowSetDefault) {
|
||||
createMissing = false;
|
||||
}
|
||||
|
||||
editOptions({
|
||||
...localOptions,
|
||||
strategy: localOptions.strategy,
|
||||
createMissing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{renderField()}</td>
|
||||
<td>{renderStrategy()}</td>
|
||||
<td>{maybeRenderCreateMissing()}</td>
|
||||
<td className="text-right">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
className="minimal text-success"
|
||||
onClick={() => onEditOptions()}
|
||||
>
|
||||
<Icon icon="check" />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
onClick={() => {
|
||||
editOptions();
|
||||
resetOptions();
|
||||
}}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="minimal" onClick={() => editField()}>
|
||||
<Icon icon="pencil-alt" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFieldOptionsList {
|
||||
fieldOptions?: GQL.IdentifyFieldOptions[];
|
||||
setFieldOptions: (o: GQL.IdentifyFieldOptions[]) => void;
|
||||
setEditingField: (v: boolean) => void;
|
||||
allowSetDefault?: boolean;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
|
||||
fieldOptions,
|
||||
setFieldOptions,
|
||||
setEditingField,
|
||||
allowSetDefault = true,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const [localFieldOptions, setLocalFieldOptions] = useState<
|
||||
GQL.IdentifyFieldOptions[]
|
||||
>();
|
||||
const [editField, setEditField] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldOptions) {
|
||||
setLocalFieldOptions([...fieldOptions]);
|
||||
} else {
|
||||
setLocalFieldOptions([]);
|
||||
}
|
||||
}, [fieldOptions]);
|
||||
|
||||
function handleEditOptions(o?: GQL.IdentifyFieldOptions | null) {
|
||||
if (!localFieldOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (o !== undefined) {
|
||||
const newOptions = [...localFieldOptions];
|
||||
const index = newOptions.findIndex(
|
||||
(option) => option.field === editField
|
||||
);
|
||||
if (index !== -1) {
|
||||
// if null, then we're removing
|
||||
if (o === null) {
|
||||
newOptions.splice(index, 1);
|
||||
} else {
|
||||
// replace in list
|
||||
newOptions.splice(index, 1, o);
|
||||
}
|
||||
} else if (o !== null) {
|
||||
// don't add if null
|
||||
newOptions.push(o);
|
||||
}
|
||||
|
||||
setFieldOptions(newOptions);
|
||||
}
|
||||
|
||||
setEditField(undefined);
|
||||
setEditingField(false);
|
||||
}
|
||||
|
||||
function onEditField(field: string) {
|
||||
setEditField(field);
|
||||
setEditingField(true);
|
||||
}
|
||||
|
||||
if (!localFieldOptions) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group className="scraper-sources">
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.field_options" />
|
||||
</h5>
|
||||
<Table responsive className="field-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-25">Field</th>
|
||||
<th className="w-25">Strategy</th>
|
||||
<th className="w-25">Create missing</th>
|
||||
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
|
||||
<th className="w-25" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sceneFields.map((f) => (
|
||||
<FieldOptionsEditor
|
||||
key={f}
|
||||
field={f}
|
||||
allowSetDefault={allowSetDefault}
|
||||
options={localFieldOptions.find((o) => o.field === f)}
|
||||
editField={() => onEditField(f)}
|
||||
editOptions={handleEditOptions}
|
||||
editing={f === editField}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
451
ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx
Normal file
451
ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Button, Form, Spinner } from "react-bootstrap";
|
||||
import {
|
||||
mutateMetadataIdentify,
|
||||
useConfiguration,
|
||||
useConfigureDefaults,
|
||||
useListSceneScrapers,
|
||||
} from "src/core/StashService";
|
||||
import { Icon, Modal } from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { withoutTypename } from "src/utils";
|
||||
import {
|
||||
SCRAPER_PREFIX,
|
||||
STASH_BOX_PREFIX,
|
||||
} from "src/components/Tagger/constants";
|
||||
import { DirectorySelectionDialog } from "src/components/Settings/SettingsTasksPanel/DirectorySelectionDialog";
|
||||
import { Manual } from "src/components/Help/Manual";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
import { SourcesEditor, SourcesList } from "./Sources";
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
includeMalePerformers: true,
|
||||
setCoverImage: true,
|
||||
setOrganized: false,
|
||||
};
|
||||
}
|
||||
|
||||
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.listSceneScrapers;
|
||||
|
||||
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 id="selected-identify-folders">
|
||||
<div>
|
||||
{message}
|
||||
<div>
|
||||
<Button
|
||||
title={intl.formatMessage({ id: "actions.select_folders" })}
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
<Icon icon="folder-open" />
|
||||
</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,
|
||||
};
|
||||
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({
|
||||
content: 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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} 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 (
|
||||
<Modal
|
||||
modalProps={{ animation, size: "lg" }}
|
||||
show
|
||||
icon="cogs"
|
||||
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={
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={editingField || savingDefaults}
|
||||
onClick={() => setAsDefault()}
|
||||
>
|
||||
{savingDefaults && (
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
)}
|
||||
<FormattedMessage id="actions.set_as_default" />
|
||||
</Button>
|
||||
}
|
||||
leftFooterButtons={
|
||||
<Button
|
||||
title="Help"
|
||||
className="minimal help-button"
|
||||
onClick={() => onShowManual()}
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdentifyDialog;
|
||||
117
ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx
Normal file
117
ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { FieldOptionsList } from "./FieldOptions";
|
||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||
|
||||
interface IOptionsEditor {
|
||||
options: GQL.IdentifyMetadataOptionsInput;
|
||||
setOptions: (s: GQL.IdentifyMetadataOptionsInput) => void;
|
||||
source?: IScraperSource;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
setEditingField: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const OptionsEditor: React.FC<IOptionsEditor> = ({
|
||||
options,
|
||||
setOptions: setOptionsState,
|
||||
source,
|
||||
setEditingField,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
function setOptions(v: Partial<GQL.IdentifyMetadataOptionsInput>) {
|
||||
setOptionsState({ ...options, ...v });
|
||||
}
|
||||
|
||||
const headingID = !source
|
||||
? "config.tasks.identify.default_options"
|
||||
: "config.tasks.identify.source_options";
|
||||
const checkboxProps = {
|
||||
allowUndefined: !!source,
|
||||
indeterminateClassname: "text-muted",
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Group>
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id={headingID}
|
||||
values={{ source: source?.displayName }}
|
||||
/>
|
||||
</h5>
|
||||
{!source && (
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.tasks.identify.explicit_set_description",
|
||||
})}
|
||||
</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<ThreeStateBoolean
|
||||
id="include-male-performers"
|
||||
value={
|
||||
options.includeMalePerformers === null
|
||||
? undefined
|
||||
: options.includeMalePerformers
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
includeMalePerformers: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.include_male_performers",
|
||||
})}
|
||||
defaultValue={defaultOptions?.includeMalePerformers ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
<ThreeStateBoolean
|
||||
id="set-cover-image"
|
||||
value={
|
||||
options.setCoverImage === null ? undefined : options.setCoverImage
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
setCoverImage: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.set_cover_images",
|
||||
})}
|
||||
defaultValue={defaultOptions?.setCoverImage ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
<ThreeStateBoolean
|
||||
id="set-organized"
|
||||
value={
|
||||
options.setOrganized === null ? undefined : options.setOrganized
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
setOrganized: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.set_organized",
|
||||
})}
|
||||
defaultValue={defaultOptions?.setOrganized ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<FieldOptionsList
|
||||
fieldOptions={options.fieldOptions ?? undefined}
|
||||
setFieldOptions={(o) => setOptions({ fieldOptions: o })}
|
||||
setEditingField={setEditingField}
|
||||
allowSetDefault={!!source}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
216
ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx
Normal file
216
ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Button, ListGroup } from "react-bootstrap";
|
||||
import { Modal, Icon } from "src/components/Shared";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
|
||||
interface ISourceEditor {
|
||||
isNew: boolean;
|
||||
availableSources: IScraperSource[];
|
||||
source: IScraperSource;
|
||||
saveSource: (s?: IScraperSource) => void;
|
||||
defaultOptions: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const SourcesEditor: React.FC<ISourceEditor> = ({
|
||||
isNew,
|
||||
availableSources,
|
||||
source: initialSource,
|
||||
saveSource,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const [source, setSource] = useState<IScraperSource>(initialSource);
|
||||
const [editingField, setEditingField] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// if id is empty, then we are adding a new source
|
||||
const headerMsgId = isNew ? "actions.add" : "dialogs.edit_entity_title";
|
||||
const acceptMsgId = isNew ? "actions.add" : "actions.confirm";
|
||||
|
||||
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const selectedSource = availableSources.find(
|
||||
(s) => s.id === e.currentTarget.value
|
||||
);
|
||||
if (!selectedSource) return;
|
||||
|
||||
setSource({
|
||||
...source,
|
||||
id: selectedSource.id,
|
||||
displayName: selectedSource.displayName,
|
||||
scraper_id: selectedSource.scraper_id,
|
||||
stash_box_endpoint: selectedSource.stash_box_endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dialogClassName="identify-source-editor"
|
||||
modalProps={{ animation: false, size: "lg" }}
|
||||
show
|
||||
icon={isNew ? "plus" : "pencil-alt"}
|
||||
header={intl.formatMessage(
|
||||
{ id: headerMsgId },
|
||||
{
|
||||
count: 1,
|
||||
singularEntity: source?.displayName,
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: () => saveSource(source),
|
||||
text: intl.formatMessage({ id: acceptMsgId }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => saveSource(),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
disabled={
|
||||
(!source.scraper_id && !source.stash_box_endpoint) || editingField
|
||||
}
|
||||
>
|
||||
<Form>
|
||||
{isNew && (
|
||||
<Form.Group>
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.source" />
|
||||
</h5>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={source.id}
|
||||
className="input-control"
|
||||
onChange={handleSourceSelect}
|
||||
>
|
||||
{availableSources.map((i) => (
|
||||
<option value={i.id} key={i.id}>
|
||||
{i.displayName}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
)}
|
||||
<OptionsEditor
|
||||
options={source.options ?? {}}
|
||||
setOptions={(o) => setSource({ ...source, options: o })}
|
||||
source={source}
|
||||
setEditingField={(v) => setEditingField(v)}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISourcesList {
|
||||
sources: IScraperSource[];
|
||||
setSources: (s: IScraperSource[]) => void;
|
||||
editSource: (s?: IScraperSource) => void;
|
||||
canAdd: boolean;
|
||||
}
|
||||
|
||||
export const SourcesList: React.FC<ISourcesList> = ({
|
||||
sources,
|
||||
setSources,
|
||||
editSource,
|
||||
canAdd,
|
||||
}) => {
|
||||
const [tempSources, setTempSources] = useState(sources);
|
||||
const [dragIndex, setDragIndex] = useState<number | undefined>();
|
||||
const [mouseOverIndex, setMouseOverIndex] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setTempSources([...sources]);
|
||||
}, [sources]);
|
||||
|
||||
function removeSource(index: number) {
|
||||
const newSources = [...sources];
|
||||
newSources.splice(index, 1);
|
||||
setSources(newSources);
|
||||
}
|
||||
|
||||
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
|
||||
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
|
||||
const newSources = [...tempSources];
|
||||
const moved = newSources.splice(dragIndex, 1);
|
||||
newSources.splice(index, 0, moved[0]);
|
||||
setTempSources(newSources);
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop() {
|
||||
// assume we've already set the temp source list
|
||||
// feed it up
|
||||
setSources(tempSources);
|
||||
setDragIndex(undefined);
|
||||
setMouseOverIndex(undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group className="scraper-sources" onDragOver={onDragOverDefault}>
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.sources" />
|
||||
</h5>
|
||||
<ListGroup as="ul" className="scraper-source-list">
|
||||
{tempSources.map((s, index) => (
|
||||
<ListGroup.Item
|
||||
as="li"
|
||||
key={s.id}
|
||||
className="d-flex justify-content-between align-items-center"
|
||||
draggable={mouseOverIndex === index}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragEnter={(e) => onDragOver(e, index)}
|
||||
onDrop={() => onDrop()}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="minimal text-muted drag-handle"
|
||||
onMouseEnter={() => setMouseOverIndex(index)}
|
||||
onMouseLeave={() => setMouseOverIndex(undefined)}
|
||||
>
|
||||
<Icon icon="grip-vertical" />
|
||||
</div>
|
||||
{s.displayName}
|
||||
</div>
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => editSource(s)}>
|
||||
<Icon icon="cog" />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
</div>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
{canAdd && (
|
||||
<div className="text-right">
|
||||
<Button
|
||||
className="minimal add-scraper-source-button"
|
||||
onClick={() => editSource()}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IThreeStateBoolean {
|
||||
id: string;
|
||||
value: boolean | undefined;
|
||||
setValue: (v: boolean | undefined) => void;
|
||||
allowUndefined?: boolean;
|
||||
label?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||
id,
|
||||
value,
|
||||
setValue,
|
||||
allowUndefined = true,
|
||||
label,
|
||||
disabled,
|
||||
defaultValue,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!allowUndefined) {
|
||||
return (
|
||||
<Form.Check
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
checked={value}
|
||||
label={label}
|
||||
onChange={() => setValue(!value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getBooleanText(v: boolean) {
|
||||
if (v) {
|
||||
return intl.formatMessage({ id: "true" });
|
||||
}
|
||||
return intl.formatMessage({ id: "false" });
|
||||
}
|
||||
|
||||
function getButtonText(v: boolean | undefined) {
|
||||
if (v === undefined) {
|
||||
const defaultVal =
|
||||
defaultValue !== undefined ? (
|
||||
<span className="default-value">
|
||||
{" "}
|
||||
({getBooleanText(defaultValue)})
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
return (
|
||||
<span>
|
||||
{intl.formatMessage({ id: "use_default" })}
|
||||
{defaultVal}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return getBooleanText(v);
|
||||
}
|
||||
|
||||
function renderModeButton(v: boolean | undefined) {
|
||||
return (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id={`${id}-value-${v ?? "undefined"}`}
|
||||
checked={value === v}
|
||||
onChange={() => setValue(v)}
|
||||
disabled={disabled}
|
||||
label={getButtonText(v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<h6>{label}</h6>
|
||||
<Form.Group>
|
||||
{renderModeButton(undefined)}
|
||||
{renderModeButton(false)}
|
||||
{renderModeButton(true)}
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
27
ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts
Normal file
27
ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
||||
export interface IScraperSource {
|
||||
id: string;
|
||||
displayName: string;
|
||||
stash_box_endpoint?: string;
|
||||
scraper_id?: string;
|
||||
options?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const sceneFields = [
|
||||
"title",
|
||||
"date",
|
||||
"details",
|
||||
"url",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"stash_ids",
|
||||
] as const;
|
||||
export type SceneField = typeof sceneFields[number];
|
||||
|
||||
export const multiValueSceneFields: SceneField[] = [
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
];
|
||||
45
ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss
Normal file
45
ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.identify-source-editor {
|
||||
.default-value {
|
||||
color: #bfccd6;
|
||||
}
|
||||
}
|
||||
|
||||
.scraper-source-list {
|
||||
.list-group-item {
|
||||
background-color: $textfield-bg;
|
||||
padding: 0.25em;
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
display: inline-block;
|
||||
margin: -0.25em 0.25em -0.25em -0.25em;
|
||||
padding: 0.25em 0.5em 0.25em;
|
||||
}
|
||||
|
||||
.drag-handle:hover,
|
||||
.drag-handle:active,
|
||||
.drag-handle:focus,
|
||||
.drag-handle:focus:active {
|
||||
background-color: initial;
|
||||
border-color: initial;
|
||||
box-shadow: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scraper-sources {
|
||||
.add-scraper-source-button {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.field-options-table td:first-child {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
#selected-identify-folders {
|
||||
& > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user