Add batch delete for performers/tags/studios/movies (#1053)

* Add batch delete for performers/tags/studios/movies
* Fix ListFilter styling bug
This commit is contained in:
InfiniteTF 2021-01-13 01:57:53 +01:00 committed by GitHub
parent 8a3d940aa7
commit aad4ddc46d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 353 additions and 56 deletions

View File

@ -24,4 +24,8 @@ mutation MovieUpdate($input: MovieUpdateInput!) {
mutation MovieDestroy($id: ID!) {
movieDestroy(input: { id: $id })
}
}
mutation MoviesDestroy($ids: [ID!]!) {
moviesDestroy(ids: $ids)
}

View File

@ -55,3 +55,7 @@ mutation PerformerUpdate(
mutation PerformerDestroy($id: ID!) {
performerDestroy(input: { id: $id })
}
mutation PerformersDestroy($ids: [ID!]!) {
performersDestroy(ids: $ids)
}

View File

@ -21,3 +21,7 @@ mutation StudioUpdate(
mutation StudioDestroy($id: ID!) {
studioDestroy(input: { id: $id })
}
mutation StudiosDestroy($ids: [ID!]!) {
studiosDestroy(ids: $ids)
}

View File

@ -8,8 +8,12 @@ mutation TagDestroy($id: ID!) {
tagDestroy(input: { id: $id })
}
mutation TagsDestroy($ids: [ID!]!) {
tagsDestroy(ids: $ids)
}
mutation TagUpdate($input: TagUpdateInput!) {
tagUpdate(input: $input) {
...TagData
}
}
}

View File

@ -175,18 +175,22 @@ type Mutation {
performerCreate(input: PerformerCreateInput!): Performer
performerUpdate(input: PerformerUpdateInput!): Performer
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio
studioDestroy(input: StudioDestroyInput!): Boolean!
studiosDestroy(ids: [ID!]!): Boolean!
movieCreate(input: MovieCreateInput!): Movie
movieUpdate(input: MovieUpdateInput!): Movie
movieDestroy(input: MovieDestroyInput!): Boolean!
moviesDestroy(ids: [ID!]!): Boolean!
tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag
tagDestroy(input: TagDestroyInput!): Boolean!
tagsDestroy(ids: [ID!]!): Boolean!
"""Change general configuration options"""
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!

View File

@ -222,3 +222,18 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieD
}
return true, nil
}
func (r *mutationResolver) MoviesDestroy(ctx context.Context, ids []string) (bool, error) {
qb := models.NewMovieQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)
for _, id := range ids {
if err := qb.Destroy(id, tx); err != nil {
_ = tx.Rollback()
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}

View File

@ -242,3 +242,18 @@ func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.Pe
}
return true, nil
}
func (r *mutationResolver) PerformersDestroy(ctx context.Context, ids []string) (bool, error) {
qb := models.NewPerformerQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)
for _, id := range ids {
if err := qb.Destroy(id, tx); err != nil {
_ = tx.Rollback()
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}

View File

@ -182,3 +182,18 @@ func (r *mutationResolver) StudioDestroy(ctx context.Context, input models.Studi
}
return true, nil
}
func (r *mutationResolver) StudiosDestroy(ctx context.Context, ids []string) (bool, error) {
qb := models.NewStudioQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)
for _, id := range ids {
if err := qb.Destroy(id, tx); err != nil {
_ = tx.Rollback()
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}

View File

@ -152,3 +152,19 @@ func (r *mutationResolver) TagDestroy(ctx context.Context, input models.TagDestr
}
return true, nil
}
func (r *mutationResolver) TagsDestroy(ctx context.Context, ids []string) (bool, error) {
qb := models.NewTagQueryBuilder()
tx := database.DB.MustBeginTx(ctx, nil)
for _, id := range ids {
if err := qb.Destroy(id, tx); err != nil {
_ = tx.Rollback()
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}

View File

@ -6,6 +6,7 @@
* Allow configuration of visible navbar items.
### 🎨 Improvements
* Add batch deleting of performers, tags, studios, and movies.
* Reset cache after scan/clean to ensure scenes are updated.
* Add more video/image resolution tags.
* Add option to strip file extension from scene title when populating from scanning task.

View File

@ -76,11 +76,13 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
</p>
<Form>
<Form.Check
id="delete-image"
checked={deleteFile}
label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
id="delete-image-generated"
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}

View File

@ -426,29 +426,25 @@ export const ListFilter: React.FC<IListFilterProps> = (
}
function maybeRenderSelectedButtons() {
if (props.itemsSelected) {
if (props.itemsSelected && (props.onEdit || props.onDelete)) {
return (
<>
{props.onEdit ? (
<ButtonGroup className="mr-1">
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
</Button>
</OverlayTrigger>
</ButtonGroup>
) : undefined}
<ButtonGroup className="ml-2">
{props.onEdit && (
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
</Button>
</OverlayTrigger>
)}
{props.onDelete ? (
<ButtonGroup className="mr-1">
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}>
<Button variant="danger" onClick={onDelete}>
<Icon icon="trash" />
</Button>
</OverlayTrigger>
</ButtonGroup>
) : undefined}
</>
{props.onDelete && (
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}>
<Button variant="danger" onClick={onDelete}>
<Icon icon="trash" />
</Button>
</OverlayTrigger>
)}
</ButtonGroup>
);
}
}
@ -456,8 +452,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
function render() {
return (
<>
<ButtonToolbar className="align-items-center justify-content-center">
<div className="my-1 d-flex">
<ButtonToolbar className="align-items-center justify-content-center mb-2">
<div className="d-flex">
<InputGroup className="mr-2 flex-grow-1">
<FormControl
ref={queryRef}
@ -530,18 +526,15 @@ export const ListFilter: React.FC<IListFilterProps> = (
))}
</Form.Control>
<ButtonGroup className="mx-3 my-1">
{maybeRenderSelectedButtons()}
{renderMore()}
</ButtonGroup>
{maybeRenderSelectedButtons()}
<ButtonGroup className="my-1">
{renderDisplayModeOptions()}
</ButtonGroup>
<div className="mx-2">{renderMore()}</div>
<ButtonGroup>{renderDisplayModeOptions()}</ButtonGroup>
{maybeRenderZoom()}
</ButtonToolbar>
<div className="d-flex justify-content-center mt-1">
<div className="d-flex justify-content-center">
{renderFilterTags()}
</div>
</>

View File

@ -1,14 +1,17 @@
import React, { useState } from "react";
import _ from "lodash";
import Mousetrap from "mousetrap";
import { FindMoviesQueryResult } from "src/core/generated-graphql";
import { useHistory } from "react-router-dom";
import {
FindMoviesQueryResult,
SlimMovieDataFragment,
} from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindMovies } from "src/core/StashService";
import { queryFindMovies, useMoviesDestroy } from "src/core/StashService";
import { showWhenSelected, useMoviesList } from "src/hooks/ListHook";
import { useHistory } from "react-router-dom";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { MovieCard } from "./MovieCard";
import { ExportDialog } from "../Shared/ExportDialog";
export const MovieList: React.FC = () => {
const history = useHistory();
@ -44,12 +47,26 @@ export const MovieList: React.FC = () => {
};
};
const renderDeleteDialog = (
selectedMovies: SlimMovieDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedMovies}
onClose={onClose}
singularEntity="movie"
pluralEntity="movies"
destroyMutation={useMoviesDestroy}
/>
);
const listData = useMoviesList({
renderContent,
addKeybinds,
otherOperations,
selectable: true,
persistState: true,
renderDeleteDialog,
});
async function viewRandom(

View File

@ -2,13 +2,19 @@ import _ from "lodash";
import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import { FindPerformersQueryResult } from "src/core/generated-graphql";
import { queryFindPerformers } from "src/core/StashService";
import {
FindPerformersQueryResult,
SlimPerformerDataFragment,
} from "src/core/generated-graphql";
import {
queryFindPerformers,
usePerformersDestroy,
} from "src/core/StashService";
import { usePerformersList } from "src/hooks";
import { showWhenSelected } from "src/hooks/ListHook";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { ExportDialog } from "src/components/Shared/ExportDialog";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable";
@ -76,12 +82,26 @@ export const PerformerList: React.FC = () => {
}
}
const renderDeleteDialog = (
selectedPerformers: SlimPerformerDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedPerformers}
onClose={onClose}
singularEntity="performer"
pluralEntity="performers"
destroyMutation={usePerformersDestroy}
/>
);
const listData = usePerformersList({
otherOperations,
renderContent,
addKeybinds,
selectable: true,
persistState: true,
renderDeleteDialog,
});
async function getRandom(

View File

@ -76,11 +76,13 @@ export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
</p>
<Form>
<Form.Check
id="delete-file"
checked={deleteFile}
label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
id="delete-generated"
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}

View File

@ -68,13 +68,18 @@ export const SceneList: React.FC<ISceneList> = ({
};
};
const renderDeleteDialog = (
selectedScenes: SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) => <DeleteScenesDialog selected={selectedScenes} onClose={onClose} />;
const listData = useScenesList({
zoomable: true,
selectable: true,
otherOperations,
renderContent,
renderEditDialog: renderEditScenesDialog,
renderDeleteDialog: renderDeleteScenesDialog,
renderDeleteDialog,
filterHook,
addKeybinds,
persistState,
@ -166,17 +171,6 @@ export const SceneList: React.FC<ISceneList> = ({
);
}
function renderDeleteScenesDialog(
selectedScenes: SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<>
<DeleteScenesDialog selected={selectedScenes} onClose={onClose} />
</>
);
}
function renderSceneCard(
scene: SlimSceneDataFragment,
selectedIds: Set<string>,

View File

@ -0,0 +1,123 @@
import React, { useState } from "react";
import { defineMessages, FormattedMessage, useIntl } from "react-intl";
import { FetchResult } from "@apollo/client";
import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
interface IDeletionEntity {
id: string;
name?: string | null;
}
type DestroyMutation = (input: {
ids: string[];
}) => [() => Promise<FetchResult>, {}];
interface IDeleteEntityDialogProps {
selected: IDeletionEntity[];
onClose: (confirmed: boolean) => void;
singularEntity: string;
pluralEntity: string;
destroyMutation: DestroyMutation;
}
const messages = defineMessages({
deleteHeader: {
id: "delete-header",
defaultMessage:
"Delete {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}",
},
deleteToast: {
id: "delete-toast",
defaultMessage:
"Deleted {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}",
},
deleteMessage: {
id: "delete-message",
defaultMessage:
"Are you sure you want to delete {count, plural, =1 {this {singularEntity}} other {these {pluralEntity}}}?",
},
overflowMessage: {
id: "overflow-message",
defaultMessage:
"...and {count} other {count, plural, =1 {{ singularEntity}} other {{ pluralEntity }}}.",
},
});
const DeleteEntityDialog: React.FC<IDeleteEntityDialogProps> = ({
selected,
onClose,
singularEntity,
pluralEntity,
destroyMutation,
}) => {
const intl = useIntl();
const Toast = useToast();
const [deleteEntities] = destroyMutation({ ids: selected.map((p) => p.id) });
const count = selected.length;
// Network state
const [isDeleting, setIsDeleting] = useState(false);
async function onDelete() {
setIsDeleting(true);
try {
await deleteEntities();
Toast.success({
content: intl.formatMessage(messages.deleteToast, {
count,
singularEntity,
pluralEntity,
}),
});
} catch (e) {
Toast.error(e);
}
setIsDeleting(false);
onClose(true);
}
return (
<Modal
show
icon="trash-alt"
header={intl.formatMessage(messages.deleteHeader, {
count,
singularEntity,
pluralEntity,
})}
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
cancel={{
onClick: () => onClose(false),
text: "Cancel",
variant: "secondary",
}}
isRunning={isDeleting}
>
<p>
<FormattedMessage
values={{ count, singularEntity, pluralEntity }}
{...messages.deleteMessage}
/>
</p>
<ul>
{selected.slice(0, 10).map((s) => (
<li>{s.name}</li>
))}
{selected.length > 10 && (
<FormattedMessage
values={{
count: selected.length - 10,
singularEntity,
pluralEntity,
}}
{...messages.overflowMessage}
/>
)}
</ul>
</Modal>
);
};
export default DeleteEntityDialog;

View File

@ -24,3 +24,5 @@ export { default as ErrorMessage } from "./ErrorMessage";
export { default as TruncatedText } from "./TruncatedText";
export { BasicCard } from "./BasicCard";
export { RatingStars } from "./RatingStars";
export { ExportDialog } from "./ExportDialog";
export { default as DeleteEntityDialog } from "./DeleteEntityDialog";

View File

@ -2,13 +2,16 @@ import React, { useState } from "react";
import _ from "lodash";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import { FindStudiosQueryResult } from "src/core/generated-graphql";
import {
FindStudiosQueryResult,
SlimStudioDataFragment,
} from "src/core/generated-graphql";
import { useStudiosList } from "src/hooks";
import { showWhenSelected } from "src/hooks/ListHook";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindStudios } from "src/core/StashService";
import { ExportDialog } from "../Shared/ExportDialog";
import { queryFindStudios, useStudiosDestroy } from "src/core/StashService";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { StudioCard } from "./StudioCard";
interface IStudioList {
@ -109,6 +112,19 @@ export const StudioList: React.FC<IStudioList> = ({
}
}
const renderDeleteDialog = (
selectedStudios: SlimStudioDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedStudios}
onClose={onClose}
singularEntity="studio"
pluralEntity="studios"
destroyMutation={useStudiosDestroy}
/>
);
const listData = useStudiosList({
renderContent,
filterHook,
@ -116,6 +132,7 @@ export const StudioList: React.FC<IStudioList> = ({
otherOperations,
selectable: true,
persistState: !fromParent,
renderDeleteDialog,
});
function renderStudios(

View File

@ -12,11 +12,12 @@ import {
queryFindTags,
mutateMetadataAutoTag,
useTagDestroy,
useTagsDestroy,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { FormattedNumber } from "react-intl";
import { NavUtils } from "src/utils";
import { Icon, Modal } from "src/components/Shared";
import { Icon, Modal, DeleteEntityDialog } from "src/components/Shared";
import { TagCard } from "./TagCard";
import { ExportDialog } from "../Shared/ExportDialog";
@ -121,6 +122,19 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
}
}
const renderDeleteDialog = (
selectedTags: GQL.TagDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedTags}
onClose={onClose}
singularEntity="tag"
pluralEntity="tags"
destroyMutation={useTagsDestroy}
/>
);
const listData = useTagsList({
renderContent,
filterHook,
@ -130,6 +144,7 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
zoomable: true,
defaultZoomIndex: 0,
persistState: true,
renderDeleteDialog,
});
function getDeleteTagInput() {

View File

@ -309,6 +309,18 @@ export const usePerformerDestroy = () =>
update: deleteCache(performerMutationImpactedQueries),
});
export const usePerformersDestroy = (
variables: GQL.PerformersDestroyMutationVariables
) =>
GQL.usePerformersDestroyMutation({
variables,
refetchQueries: getQueryNames([
GQL.FindPerformersDocument,
GQL.AllPerformersForFilterDocument,
]),
update: deleteCache(performerMutationImpactedQueries),
});
const sceneMutationImpactedQueries = [
GQL.FindPerformerDocument,
GQL.FindPerformersDocument,
@ -562,6 +574,12 @@ export const useStudioDestroy = (input: GQL.StudioDestroyInput) =>
update: deleteCache(studioMutationImpactedQueries),
});
export const useStudiosDestroy = (input: GQL.StudiosDestroyMutationVariables) =>
GQL.useStudiosDestroyMutation({
variables: input,
update: deleteCache(studioMutationImpactedQueries),
});
export const movieMutationImpactedQueries = [
GQL.FindSceneDocument,
GQL.FindScenesDocument,
@ -589,6 +607,12 @@ export const useMovieDestroy = (input: GQL.MovieDestroyInput) =>
update: deleteCache(movieMutationImpactedQueries),
});
export const useMoviesDestroy = (input: GQL.MoviesDestroyMutationVariables) =>
GQL.useMoviesDestroyMutation({
variables: input,
update: deleteCache(movieMutationImpactedQueries),
});
export const tagMutationImpactedQueries = [
GQL.FindSceneDocument,
GQL.FindScenesDocument,
@ -622,6 +646,12 @@ export const useTagDestroy = (input: GQL.TagDestroyInput) =>
update: deleteCache(tagMutationImpactedQueries),
});
export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) =>
GQL.useTagsDestroyMutation({
variables: input,
update: deleteCache(tagMutationImpactedQueries),
});
export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) =>
GQL.useConfigureGeneralMutation({
variables: { input },