Identify task (#1839)

* Add identify task
* Change type naming
* Debounce folder select text input
* Add generic slice comparison function
This commit is contained in:
WithoutPants
2021-10-28 14:25:17 +11:00
committed by GitHub
parent c93b5e12b7
commit 0f64954e5b
70 changed files with 5882 additions and 291 deletions

View 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>
);
};

View 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;

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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",
];

View 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;
}
}