Refactor tasks follow up (#2061)

* Move scan options out of dialog
* Move autotag and clean options out of dialogs
* Move generate options out of dialog
* Animate button while tasks running
* Revert to earlier Tasks UI iteration
* Rearrange and clarify scan options
This commit is contained in:
WithoutPants
2021-11-30 09:45:36 +11:00
committed by GitHub
parent cf4ab843f6
commit 7c44a9c993
28 changed files with 1277 additions and 1579 deletions

View File

@@ -9,7 +9,7 @@ import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
import { SettingsScrapingPanel } from "./SettingsScrapingPanel";
import { SettingsToolsPanel } from "./SettingsToolsPanel";

View File

@@ -1,587 +0,0 @@
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
import {
mutateMetadataImport,
mutateMetadataExport,
mutateMigrateHashNaming,
usePlugins,
mutateRunPluginTask,
mutateBackupDatabase,
mutateMetadataScan,
mutateMetadataIdentify,
mutateMetadataAutoTag,
mutateMetadataGenerate,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator, Modal } from "src/components/Shared";
import { downloadFile, withoutTypename } from "src/utils";
import IdentifyDialog from "src/components/Dialogs/IdentifyDialog/IdentifyDialog";
import { ImportDialog } from "./ImportDialog";
import { JobTable } from "./JobTable";
import ScanDialog from "src/components/Dialogs/ScanDialog/ScanDialog";
import AutoTagDialog from "src/components/Dialogs/AutoTagDialog";
import { GenerateDialog } from "src/components/Dialogs/GenerateDialog";
import CleanDialog from "src/components/Dialogs/CleanDialog";
import { ConfigurationContext } from "src/hooks/Config";
import { PropsWithChildren } from "react-router/node_modules/@types/react";
type Plugin = Pick<GQL.Plugin, "id">;
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
interface ITask {
description?: React.ReactNode;
}
const Task: React.FC<PropsWithChildren<ITask>> = ({
children,
description,
}) => (
<div className="task">
{children}
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>
) : undefined}
</div>
);
export const SettingsTasksPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const [dialogOpen, setDialogOpenState] = useState({
importAlert: false,
import: false,
clean: false,
scan: false,
autoTag: false,
identify: false,
generate: false,
});
type DialogOpenState = typeof dialogOpen;
const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false);
const { configuration } = React.useContext(ConfigurationContext);
const plugins = usePlugins();
function setDialogOpen(s: Partial<DialogOpenState>) {
setDialogOpenState((v) => {
return { ...v, ...s };
});
}
async function onImport() {
setDialogOpen({ importAlert: false });
try {
await mutateMetadataImport();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.import" }) }
),
});
} catch (e) {
Toast.error(e);
}
}
function renderImportAlert() {
return (
<Modal
show={dialogOpen.importAlert}
icon="trash-alt"
accept={{
text: intl.formatMessage({ id: "actions.import" }),
variant: "danger",
onClick: onImport,
}}
cancel={{ onClick: () => setDialogOpen({ importAlert: false }) }}
>
<p>{intl.formatMessage({ id: "actions.tasks.import_warning" })}</p>
</Modal>
);
}
function renderCleanDialog() {
if (!dialogOpen.clean) {
return;
}
return <CleanDialog onClose={() => setDialogOpen({ clean: false })} />;
}
function renderImportDialog() {
if (!dialogOpen.import) {
return;
}
return <ImportDialog onClose={() => setDialogOpen({ import: false })} />;
}
function renderScanDialog() {
if (!dialogOpen.scan) {
return;
}
return <ScanDialog onClose={() => setDialogOpen({ scan: false })} />;
}
function renderAutoTagDialog() {
if (!dialogOpen.autoTag) {
return;
}
return <AutoTagDialog onClose={() => setDialogOpen({ autoTag: false })} />;
}
function maybeRenderIdentifyDialog() {
if (!dialogOpen.identify) return;
return (
<IdentifyDialog onClose={() => setDialogOpen({ identify: false })} />
);
}
function maybeRenderGenerateDialog() {
if (!dialogOpen.generate) return;
return (
<GenerateDialog onClose={() => setDialogOpen({ generate: false })} />
);
}
async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) {
await mutateRunPluginTask(plugin.id, operation.name);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: operation.name }
),
});
}
function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) {
if (!pluginTasks) {
return;
}
return pluginTasks.map((o) => {
return (
<Task description={o.description} key={o.name}>
<Button
onClick={() => onPluginTaskClicked(plugin, o)}
variant="secondary"
size="sm"
>
{o.name}
</Button>
</Task>
);
});
}
async function onBackup(download?: boolean) {
try {
setIsBackupRunning(true);
const ret = await mutateBackupDatabase({
download,
});
// download the result
if (download && ret.data && ret.data.backupDatabase) {
const link = ret.data.backupDatabase;
downloadFile(link);
}
} catch (e) {
Toast.error(e);
} finally {
setIsBackupRunning(false);
}
}
function renderPlugins() {
if (!plugins.data || !plugins.data.plugins) {
return;
}
const taskPlugins = plugins.data.plugins.filter(
(p) => p.tasks && p.tasks.length > 0
);
return (
<>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
{taskPlugins.map((o) => {
return (
<Form.Group key={`${o.id}`}>
<h6>{o.name}</h6>
<Card className="task-group">
{renderPluginTasks(o, o.tasks ?? [])}
</Card>
</Form.Group>
);
})}
</Form.Group>
</>
);
}
async function onMigrateHashNaming() {
try {
await mutateMigrateHashNaming();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{
operation_name: intl.formatMessage({
id: "actions.hash_migration",
}),
}
),
});
} catch (err) {
Toast.error(err);
}
}
async function onExport() {
try {
await mutateMetadataExport();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.backup" }) }
),
});
} catch (err) {
Toast.error(err);
}
}
async function onScanClicked() {
// check if defaults are set for scan
// if not, then open the dialog
if (!configuration) {
return;
}
const { scan } = configuration?.defaults;
if (!scan) {
setDialogOpen({ scan: true });
} else {
mutateMetadataScan(withoutTypename(scan));
}
}
async function onIdentifyClicked() {
// check if defaults are set for identify
// if not, then open the dialog
if (!configuration) {
return;
}
const { identify } = configuration?.defaults;
if (!identify) {
setDialogOpen({ identify: true });
} else {
mutateMetadataIdentify(withoutTypename(identify));
}
}
async function onAutoTagClicked() {
// check if defaults are set for auto tag
// if not, then open the dialog
if (!configuration) {
return;
}
const { autoTag } = configuration?.defaults;
if (!autoTag) {
setDialogOpen({ autoTag: true });
} else {
mutateMetadataAutoTag(withoutTypename(autoTag));
}
}
async function onGenerateClicked() {
// check if defaults are set for generate
// if not, then open the dialog
if (!configuration) {
return;
}
const { generate } = configuration?.defaults;
if (!generate) {
setDialogOpen({ generate: true });
} else {
mutateMetadataGenerate(withoutTypename(generate));
}
}
if (isBackupRunning) {
return (
<LoadingIndicator
message={intl.formatMessage({ id: "config.tasks.backing_up_database" })}
/>
);
}
return (
<>
{renderImportAlert()}
{renderCleanDialog()}
{renderImportDialog()}
{renderScanDialog()}
{renderAutoTagDialog()}
{maybeRenderIdentifyDialog()}
{maybeRenderGenerateDialog()}
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
<JobTable />
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "library" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.scan_for_content_desc",
})}
>
<ButtonGroup className="ellipsis-button">
<Button
variant="secondary"
type="submit"
onClick={() => onScanClicked()}
>
<FormattedMessage id="actions.scan" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ scan: true })}
>
</Button>
</ButtonGroup>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.identify.description",
})}
>
<ButtonGroup className="ellipsis-button">
<Button
variant="secondary"
type="submit"
onClick={() => onIdentifyClicked()}
>
<FormattedMessage id="actions.identify" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ identify: true })}
>
</Button>
</ButtonGroup>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.auto_tag_based_on_filenames",
})}
>
<ButtonGroup className="ellipsis-button">
<Button
variant="secondary"
type="submit"
onClick={() => onAutoTagClicked()}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ autoTag: true })}
>
</Button>
</ButtonGroup>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.cleanup_desc",
})}
>
<Button
variant="danger"
type="submit"
onClick={() => setDialogOpen({ clean: true })}
>
<FormattedMessage id="actions.clean" />
</Button>
</Task>
</Card>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.generate_desc",
})}
>
<ButtonGroup className="ellipsis-button">
<Button
variant="secondary"
type="submit"
onClick={() => onGenerateClicked()}
>
<FormattedMessage id="actions.generate" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ generate: true })}
>
</Button>
</ButtonGroup>
</Task>
</Card>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.export_to_json",
})}
>
<Button
id="export"
variant="secondary"
type="submit"
onClick={() => onExport()}
>
<FormattedMessage id="actions.full_export" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.import_from_exported_json",
})}
>
<Button
id="import"
variant="danger"
type="submit"
onClick={() => setDialogOpen({ importAlert: true })}
>
<FormattedMessage id="actions.full_import" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.incremental_import",
})}
>
<Button
id="partial-import"
variant="danger"
type="submit"
onClick={() => setDialogOpen({ import: true })}
>
<FormattedMessage id="actions.import_from_file" />
</Button>
</Task>
</Card>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage(
{ id: "config.tasks.backup_database" },
{
filename_format: (
<code>
[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
</code>
),
}
)}
>
<Button
id="backup"
variant="secondary"
type="submit"
onClick={() => onBackup()}
>
<FormattedMessage id="actions.backup" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.backup_and_download",
})}
>
<Button
id="backupDownload"
variant="secondary"
type="submit"
onClick={() => onBackup(true)}
>
<FormattedMessage id="actions.download_backup" />
</Button>
</Task>
</Card>
</Form.Group>
{renderPlugins()}
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.migrate_hash_files",
})}
>
<Button
id="migrateHashNaming"
variant="danger"
onClick={() => onMigrateHashNaming()}
>
<FormattedMessage id="actions.rename_gen_files" />
</Button>
</Task>
</Card>
</Form.Group>
</>
);
};

View File

@@ -0,0 +1,359 @@
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap";
import {
mutateMigrateHashNaming,
mutateMetadataExport,
mutateBackupDatabase,
mutateMetadataImport,
mutateMetadataClean,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { downloadFile } from "src/utils";
import { Modal } from "../../Shared";
import { ImportDialog } from "./ImportDialog";
import { Task } from "./Task";
import * as GQL from "src/core/generated-graphql";
interface ICleanOptions {
options: GQL.CleanMetadataInput;
setOptions: (s: GQL.CleanMetadataInput) => void;
}
const CleanOptions: React.FC<ICleanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="clean-dryrun"
checked={options.dryRun}
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
onChange={() => setOptions({ dryRun: !options.dryRun })}
/>
</Form.Group>
);
};
interface IDataManagementTasks {
setIsBackupRunning: (v: boolean) => void;
}
export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
setIsBackupRunning,
}) => {
const intl = useIntl();
const Toast = useToast();
const [dialogOpen, setDialogOpenState] = useState({
importAlert: false,
import: false,
clean: false,
});
const [cleanOptions, setCleanOptions] = useState<GQL.CleanMetadataInput>({
dryRun: false,
});
type DialogOpenState = typeof dialogOpen;
function setDialogOpen(s: Partial<DialogOpenState>) {
setDialogOpenState((v) => {
return { ...v, ...s };
});
}
async function onImport() {
setDialogOpen({ importAlert: false });
try {
await mutateMetadataImport();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.import" }) }
),
});
} catch (e) {
Toast.error(e);
}
}
function renderImportAlert() {
return (
<Modal
show={dialogOpen.importAlert}
icon="trash-alt"
accept={{
text: intl.formatMessage({ id: "actions.import" }),
variant: "danger",
onClick: onImport,
}}
cancel={{ onClick: () => setDialogOpen({ importAlert: false }) }}
>
<p>{intl.formatMessage({ id: "actions.tasks.import_warning" })}</p>
</Modal>
);
}
function renderImportDialog() {
if (!dialogOpen.import) {
return;
}
return <ImportDialog onClose={() => setDialogOpen({ import: false })} />;
}
function renderCleanDialog() {
let msg;
if (cleanOptions.dryRun) {
msg = (
<p>{intl.formatMessage({ id: "actions.tasks.dry_mode_selected" })}</p>
);
} else {
msg = (
<p>
{intl.formatMessage({ id: "actions.tasks.clean_confirm_message" })}
</p>
);
}
return (
<Modal
show={dialogOpen.clean}
icon="trash-alt"
accept={{
text: intl.formatMessage({ id: "actions.clean" }),
variant: "danger",
onClick: onClean,
}}
cancel={{ onClick: () => setDialogOpen({ clean: false }) }}
>
{msg}
</Modal>
);
}
async function onClean() {
try {
await mutateMetadataClean(cleanOptions);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.clean" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
setDialogOpen({ clean: false });
}
}
async function onMigrateHashNaming() {
try {
await mutateMigrateHashNaming();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{
operation_name: intl.formatMessage({
id: "actions.hash_migration",
}),
}
),
});
} catch (err) {
Toast.error(err);
}
}
async function onExport() {
try {
await mutateMetadataExport();
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.backup" }) }
),
});
} catch (err) {
Toast.error(err);
}
}
async function onBackup(download?: boolean) {
try {
setIsBackupRunning(true);
const ret = await mutateBackupDatabase({
download,
});
// download the result
if (download && ret.data && ret.data.backupDatabase) {
const link = ret.data.backupDatabase;
downloadFile(link);
}
} catch (e) {
Toast.error(e);
} finally {
setIsBackupRunning(false);
}
}
return (
<Form.Group>
{renderImportAlert()}
{renderImportDialog()}
{renderCleanDialog()}
<Form.Group>
<div className="task-group">
<h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
<Task
headingID="actions.clean"
description={intl.formatMessage({
id: "config.tasks.cleanup_desc",
})}
>
<CleanOptions
options={cleanOptions}
setOptions={(o) => setCleanOptions(o)}
/>
<Button
variant="danger"
type="submit"
onClick={() => setDialogOpen({ clean: true })}
>
<FormattedMessage id="actions.clean" />
</Button>
</Task>
</div>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.export_to_json",
})}
>
<Button
id="export"
variant="secondary"
type="submit"
onClick={() => onExport()}
>
<FormattedMessage id="actions.full_export" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.import_from_exported_json",
})}
>
<Button
id="import"
variant="danger"
type="submit"
onClick={() => setDialogOpen({ importAlert: true })}
>
<FormattedMessage id="actions.full_import" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.incremental_import",
})}
>
<Button
id="partial-import"
variant="danger"
type="submit"
onClick={() => setDialogOpen({ import: true })}
>
<FormattedMessage id="actions.import_from_file" />
</Button>
</Task>
</div>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage(
{ id: "config.tasks.backup_database" },
{
filename_format: (
<code>
[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
</code>
),
}
)}
>
<Button
id="backup"
variant="secondary"
type="submit"
onClick={() => onBackup()}
>
<FormattedMessage id="actions.backup" />
</Button>
</Task>
<Task
description={intl.formatMessage({
id: "config.tasks.backup_and_download",
})}
>
<Button
id="backupDownload"
variant="secondary"
type="submit"
onClick={() => onBackup(true)}
>
<FormattedMessage id="actions.download_backup" />
</Button>
</Task>
</div>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.migrate_hash_files",
})}
>
<Button
id="migrateHashNaming"
variant="danger"
onClick={() => onMigrateHashNaming()}
>
<FormattedMessage id="actions.rename_gen_files" />
</Button>
</Task>
</div>
</Form.Group>
</Form.Group>
);
};

View File

@@ -0,0 +1,283 @@
import React, { useState } from "react";
import { Form, Button, Collapse } from "react-bootstrap";
import { Icon } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface IGenerateOptions {
options: GQL.GenerateMetadataInput;
setOptions: (s: GQL.GenerateMetadataInput) => void;
}
export const GenerateOptions: React.FC<IGenerateOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
const previewOptions: GQL.GeneratePreviewOptionsInput =
options.previewOptions ?? {};
function setOptions(input: Partial<GQL.GenerateMetadataInput>) {
setOptionsState({ ...options, ...input });
}
function setPreviewOptions(input: Partial<GQL.GeneratePreviewOptionsInput>) {
setOptions({
previewOptions: {
...previewOptions,
...input,
},
});
}
return (
<Form.Group>
<Form.Group>
<Form.Check
id="preview-task"
checked={options.previews ?? false}
label={intl.formatMessage({
id: "dialogs.scene_gen.video_previews",
})}
onChange={() => setOptions({ previews: !options.previews })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="image-preview-task"
checked={options.imagePreviews ?? false}
disabled={!options.previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() =>
setOptions({ imagePreviews: !options.imagePreviews })
}
className="ml-2 flex-grow"
/>
</div>
</Form.Group>
<Form.Group>
<Button
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
className="minimal pl-0 no-focus"
>
<Icon icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} />
<span>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_options",
})}
</span>
</Button>
<Form.Group>
<Collapse in={previewOptionsOpen}>
<Form.Group className="mt-2">
<Form.Group id="preview-preset">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
value={previewOptions.previewPreset ?? GQL.PreviewPreset.Slow}
onChange={(e) =>
setPreviewOptions({
previewPreset: e.currentTarget.value as GQL.PreviewPreset,
})
}
>
{Object.keys(GQL.PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segments">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={previewOptions.previewSegments?.toString() ?? ""}
onChange={(e) =>
setPreviewOptions({
previewSegments: Number.parseInt(
e.currentTarget.value,
10
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segment-duration">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={
previewOptions.previewSegmentDuration?.toString() ?? ""
}
onChange={(e) =>
setPreviewOptions({
previewSegmentDuration: Number.parseFloat(
e.currentTarget.value
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeStart ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeStart: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeEnd ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeEnd: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
</Collapse>
</Form.Group>
</Form.Group>
<Form.Group>
<Form.Check
id="sprite-task"
checked={options.sprites ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setOptions({ sprites: !options.sprites })}
/>
<Form.Group>
<Form.Check
id="marker-task"
checked={options.markers ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setOptions({ markers: !options.markers })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={options.markerImagePreviews ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() =>
setOptions({
markerImagePreviews: !options.markerImagePreviews,
})
}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={options.markerScreenshots ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() =>
setOptions({ markerScreenshots: !options.markerScreenshots })
}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
</Form.Group>
<Form.Group>
<Form.Check
id="transcode-task"
checked={options.transcodes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setOptions({ transcodes: !options.transcodes })}
/>
<Form.Check
id="phash-task"
checked={options.phashes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setOptions({ phashes: !options.phashes })}
/>
</Form.Group>
<hr />
<Form.Group>
<Form.Check
id="overwrite"
checked={options.overwrite ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
onChange={() => setOptions({ overwrite: !options.overwrite })}
/>
</Form.Group>
</Form.Group>
</Form.Group>
);
};

View File

@@ -0,0 +1,384 @@
import React, { useState, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap";
import {
mutateMetadataScan,
mutateMetadataAutoTag,
mutateMetadataGenerate,
useConfigureDefaults,
} from "src/core/StashService";
import { withoutTypename } from "src/utils";
import { ConfigurationContext } from "src/hooks/Config";
import { IdentifyDialog } from "../../Dialogs/IdentifyDialog/IdentifyDialog";
import * as GQL from "src/core/generated-graphql";
import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
import { ScanOptions } from "./ScanOptions";
import { useToast } from "src/hooks";
import { GenerateOptions } from "./GenerateOptions";
import { Task } from "./Task";
interface IAutoTagOptions {
options: GQL.AutoTagMetadataInput;
setOptions: (s: GQL.AutoTagMetadataInput) => void;
}
const AutoTagOptions: React.FC<IAutoTagOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const { performers, studios, tags } = options;
const wildcard = ["*"];
function toggle(v?: GQL.Maybe<string[]>) {
if (!v?.length) {
return wildcard;
}
return [];
}
function setOptions(input: Partial<GQL.AutoTagMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="autotag-performers"
checked={!!performers?.length}
label={intl.formatMessage({ id: "performers" })}
onChange={() => setOptions({ performers: toggle(performers) })}
/>
<Form.Check
id="autotag-studios"
checked={!!studios?.length}
label={intl.formatMessage({ id: "studios" })}
onChange={() => setOptions({ studios: toggle(studios) })}
/>
<Form.Check
id="autotag-tags"
checked={!!tags?.length}
label={intl.formatMessage({ id: "tags" })}
onChange={() => setOptions({ tags: toggle(tags) })}
/>
</Form.Group>
);
};
export const LibraryTasks: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const [configureDefaults] = useConfigureDefaults();
const [dialogOpen, setDialogOpenState] = useState({
clean: false,
scan: false,
autoTag: false,
identify: false,
});
const [scanOptions, setScanOptions] = useState<GQL.ScanMetadataInput>({});
const [
autoTagOptions,
setAutoTagOptions,
] = useState<GQL.AutoTagMetadataInput>({
performers: ["*"],
studios: ["*"],
tags: ["*"],
});
function getDefaultGenerateOptions(): GQL.GenerateMetadataInput {
return {
sprites: true,
phashes: true,
previews: true,
markers: true,
previewOptions: {
previewSegments: 0,
previewSegmentDuration: 0,
previewPreset: GQL.PreviewPreset.Slow,
},
};
}
const [
generateOptions,
setGenerateOptions,
] = useState<GQL.GenerateMetadataInput>(getDefaultGenerateOptions());
type DialogOpenState = typeof dialogOpen;
const { configuration } = React.useContext(ConfigurationContext);
useEffect(() => {
if (!configuration?.defaults) {
return;
}
const { scan, autoTag } = configuration.defaults;
if (scan) {
setScanOptions(withoutTypename(scan));
}
if (autoTag) {
setAutoTagOptions(withoutTypename(autoTag));
}
if (configuration?.defaults.generate) {
const { generate } = configuration.defaults;
setGenerateOptions(withoutTypename(generate));
} else if (configuration?.general) {
// backwards compatibility
const { general } = configuration;
setGenerateOptions((existing) => ({
...existing,
previewOptions: {
...existing.previewOptions,
previewSegments:
general.previewSegments ?? existing.previewOptions?.previewSegments,
previewSegmentDuration:
general.previewSegmentDuration ??
existing.previewOptions?.previewSegmentDuration,
previewExcludeStart:
general.previewExcludeStart ??
existing.previewOptions?.previewExcludeStart,
previewExcludeEnd:
general.previewExcludeEnd ??
existing.previewOptions?.previewExcludeEnd,
previewPreset:
general.previewPreset ?? existing.previewOptions?.previewPreset,
},
}));
}
}, [configuration]);
function setDialogOpen(s: Partial<DialogOpenState>) {
setDialogOpenState((v) => {
return { ...v, ...s };
});
}
function renderScanDialog() {
if (!dialogOpen.scan) {
return;
}
return <DirectorySelectionDialog onClose={onScanDialogClosed} />;
}
function onScanDialogClosed(paths?: string[]) {
if (paths) {
runScan(paths);
}
setDialogOpen({ scan: false });
}
async function runScan(paths?: string[]) {
try {
configureDefaults({
variables: {
input: {
scan: scanOptions,
},
},
});
await mutateMetadataScan({
...scanOptions,
paths,
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.scan" }) }
),
});
} catch (e) {
Toast.error(e);
}
}
function renderAutoTagDialog() {
if (!dialogOpen.autoTag) {
return;
}
return <DirectorySelectionDialog onClose={onAutoTagDialogClosed} />;
}
function onAutoTagDialogClosed(paths?: string[]) {
if (paths) {
runAutoTag(paths);
}
setDialogOpen({ autoTag: false });
}
async function runAutoTag(paths?: string[]) {
try {
configureDefaults({
variables: {
input: {
autoTag: autoTagOptions,
},
},
});
await mutateMetadataAutoTag({
...autoTagOptions,
paths,
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.auto_tag" }) }
),
});
} catch (e) {
Toast.error(e);
}
}
function maybeRenderIdentifyDialog() {
if (!dialogOpen.identify) return;
return (
<IdentifyDialog onClose={() => setDialogOpen({ identify: false })} />
);
}
async function onGenerateClicked() {
try {
configureDefaults({
variables: {
input: {
generate: generateOptions,
},
},
});
await mutateMetadataGenerate(generateOptions);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.generate" }) }
),
});
} catch (e) {
Toast.error(e);
}
}
return (
<Form.Group>
{renderScanDialog()}
{renderAutoTagDialog()}
{maybeRenderIdentifyDialog()}
<Form.Group>
<h5>{intl.formatMessage({ id: "library" })}</h5>
<div className="task-group">
<Task
headingID="actions.scan"
description={intl.formatMessage({
id: "config.tasks.scan_for_content_desc",
})}
>
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
<Button
variant="secondary"
type="submit"
className="mr-2"
onClick={() => runScan()}
>
<FormattedMessage id="actions.scan" />
</Button>
<Button
variant="secondary"
type="submit"
className="mr-2"
onClick={() => setDialogOpen({ scan: true })}
>
<FormattedMessage id="actions.selective_scan" />
</Button>
</Task>
<Task
headingID="config.tasks.identify.heading"
description={intl.formatMessage({
id: "config.tasks.identify.description",
})}
>
<Button
variant="secondary"
type="submit"
onClick={() => setDialogOpen({ identify: true })}
>
<FormattedMessage id="actions.identify" />
</Button>
</Task>
<Task
headingID="config.tasks.auto_tagging"
description={intl.formatMessage({
id: "config.tasks.auto_tag_based_on_filenames",
})}
>
<AutoTagOptions
options={autoTagOptions}
setOptions={(o) => setAutoTagOptions(o)}
/>
<Button
variant="secondary"
type="submit"
className="mr-2"
onClick={() => runAutoTag()}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button
variant="secondary"
type="submit"
onClick={() => setDialogOpen({ autoTag: true })}
>
<FormattedMessage id="actions.selective_auto_tag" />
</Button>
</Task>
</div>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.generate_desc",
})}
>
<GenerateOptions
options={generateOptions}
setOptions={setGenerateOptions}
/>
<Button
variant="secondary"
type="submit"
onClick={() => onGenerateClicked()}
>
<FormattedMessage id="actions.generate" />
</Button>
</Task>
</div>
</Form.Group>
</Form.Group>
);
};

View File

@@ -0,0 +1,75 @@
import React from "react";
import { useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap";
import { mutateRunPluginTask, usePlugins } from "src/core/StashService";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { Task } from "./Task";
type Plugin = Pick<GQL.Plugin, "id">;
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
export const PluginTasks: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const plugins = usePlugins();
function renderPlugins() {
if (!plugins.data || !plugins.data.plugins) {
return;
}
const taskPlugins = plugins.data.plugins.filter(
(p) => p.tasks && p.tasks.length > 0
);
return (
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
{taskPlugins.map((o) => {
return (
<Form.Group key={`${o.id}`}>
<h6>{o.name}</h6>
<div className="task-group">
{renderPluginTasks(o, o.tasks ?? [])}
</div>
</Form.Group>
);
})}
</Form.Group>
);
}
function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) {
if (!pluginTasks) {
return;
}
return pluginTasks.map((o) => {
return (
<Task description={o.description} key={o.name}>
<Button
onClick={() => onPluginTaskClicked(plugin, o)}
variant="secondary"
size="sm"
>
{o.name}
</Button>
</Task>
);
});
}
async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) {
await mutateRunPluginTask(plugin.id, operation.name);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: operation.name }
),
});
}
return <Form.Group>{renderPlugins()}</Form.Group>;
};

View File

@@ -0,0 +1,108 @@
import React from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface IScanOptions {
options: GQL.ScanMetadataInput;
setOptions: (s: GQL.ScanMetadataInput) => void;
}
export const ScanOptions: React.FC<IScanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const {
useFileMetadata,
stripFileExtension,
scanGeneratePreviews,
scanGenerateImagePreviews,
scanGenerateSprites,
scanGeneratePhashes,
scanGenerateThumbnails,
} = options;
function setOptions(input: Partial<GQL.ScanMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="scan-generate-previews"
checked={scanGeneratePreviews ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_video_previews_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePreviews: !scanGeneratePreviews })
}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="scan-generate-image-previews"
checked={scanGenerateImagePreviews ?? false}
disabled={!scanGeneratePreviews}
label={intl.formatMessage({
id: "config.tasks.generate_previews_during_scan",
})}
onChange={() =>
setOptions({
scanGenerateImagePreviews: !scanGenerateImagePreviews,
})
}
className="ml-2 flex-grow"
/>
</div>
<Form.Check
id="scan-generate-sprites"
checked={scanGenerateSprites ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_sprites_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateSprites: !scanGenerateSprites })
}
/>
<Form.Check
id="scan-generate-phashes"
checked={scanGeneratePhashes ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_phashes_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePhashes: !scanGeneratePhashes })
}
/>
<Form.Check
id="scan-generate-thumbnails"
checked={scanGenerateThumbnails ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_thumbnails_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails })
}
/>
<Form.Check
id="strip-file-extension"
checked={stripFileExtension ?? false}
label={intl.formatMessage({
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
})}
onChange={() => setOptions({ stripFileExtension: !stripFileExtension })}
/>
<Form.Check
id="use-file-metadata"
checked={useFileMetadata ?? false}
label={intl.formatMessage({
id: "config.tasks.set_name_date_details_from_metadata_if_present",
})}
onChange={() => setOptions({ useFileMetadata: !useFileMetadata })}
/>
</Form.Group>
);
};

View File

@@ -0,0 +1,36 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import { LoadingIndicator } from "src/components/Shared";
import { LibraryTasks } from "./LibraryTasks";
import { DataManagementTasks } from "./DataManagementTasks";
import { PluginTasks } from "./PluginTasks";
import { JobTable } from "./JobTable";
export const SettingsTasksPanel: React.FC = () => {
const intl = useIntl();
const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false);
if (isBackupRunning) {
return (
<LoadingIndicator
message={intl.formatMessage({ id: "config.tasks.backing_up_database" })}
/>
);
}
return (
<>
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
<JobTable />
<hr />
<LibraryTasks />
<hr />
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
<hr />
<PluginTasks />
</>
);
};

View File

@@ -0,0 +1,26 @@
import React, { PropsWithChildren } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
interface ITask {
headingID?: string;
description?: React.ReactNode;
}
export const Task: React.FC<PropsWithChildren<ITask>> = ({
children,
headingID,
description,
}) => (
<div className="task">
{headingID ? (
<h6>
<FormattedMessage id={headingID} />
</h6>
) : undefined}
{children}
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>
) : undefined}
</div>
);

View File

@@ -162,28 +162,20 @@
}
}
.card.task-group {
padding-bottom: 0.5rem;
.task-group {
padding-top: 0.5rem;
.task {
&:not(:last-child) {
padding-bottom: 0.5rem;
padding-top: 0.5rem;
}
.task {
&:not(:first-child) {
padding-top: 0.5rem;
}
&:not(:last-child) {
border-bottom: 1px solid $dark-gray2;
padding-bottom: 1rem;
}
}
}
.ellipsis-button {
.btn:first-child {
border-right: 1px solid $card-bg;
}
.btn:last-child {
border-left: 1px solid $card-bg;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
}