Add bulk movie update (#2283)

* Add bulk movie edit dialog
* Implement common bulk edit functions

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
mmavx
2022-02-16 03:03:57 +01:00
committed by GitHub
parent da600d19d5
commit 3fdc32b432
13 changed files with 529 additions and 569 deletions

View File

@@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) {
} }
} }
mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) {
bulkMovieUpdate(input: $input) {
...MovieData
}
}
mutation MovieDestroy($id: ID!) { mutation MovieDestroy($id: ID!) {
movieDestroy(input: { id: $id }) movieDestroy(input: { id: $id })
} }

View File

@@ -224,6 +224,7 @@ type Mutation {
movieUpdate(input: MovieUpdateInput!): Movie movieUpdate(input: MovieUpdateInput!): Movie
movieDestroy(input: MovieDestroyInput!): Boolean! movieDestroy(input: MovieDestroyInput!): Boolean!
moviesDestroy(ids: [ID!]!): Boolean! moviesDestroy(ids: [ID!]!): Boolean!
bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]
tagCreate(input: TagCreateInput!): Tag tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag

View File

@@ -54,6 +54,14 @@ input MovieUpdateInput {
back_image: String back_image: String
} }
input BulkMovieUpdateInput {
clientMutationId: String
ids: [ID!]
rating: Int
studio_id: ID
director: String
}
input MovieDestroyInput { input MovieDestroyInput {
id: ID! id: ID!
} }

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"strconv" "strconv"
"time" "time"
@@ -220,6 +221,71 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
return r.getMovie(ctx, movie.ID) return r.getMovie(ctx, movie.ID)
} }
func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input models.BulkMovieUpdateInput) ([]*models.Movie, error) {
movieIDs, err := utils.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, err
}
updatedTime := time.Now()
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedMovie := models.MoviePartial{
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating")
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director")
ret := []*models.Movie{}
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Movie()
for _, movieID := range movieIDs {
updatedMovie.ID = movieID
existing, err := qb.Find(movieID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("movie with id %d not found", movieID)
}
movie, err := qb.Update(updatedMovie)
if err != nil {
return err
}
ret = append(ret, movie)
}
return nil
}); err != nil {
return nil, err
}
var newRet []*models.Movie
for _, movie := range ret {
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, plugin.MovieUpdatePost, input, translator.getFields())
movie, err = r.getMovie(ctx, movie.ID)
if err != nil {
return nil, err
}
newRet = append(newRet, movie)
}
return newRet, nil
}
func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieDestroyInput) (bool, error) { func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieDestroyInput) (bool, error) {
id, err := strconv.Atoi(input.ID) id, err := strconv.Atoi(input.ID)
if err != nil { if err != nil {

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Added support for bulk-editing movies. ([#2283](https://github.com/stashapp/stash/pull/2283))
* Added support for filtering scenes, images and galleries featuring favourite performers and performer age at time of production. ([#2257](https://github.com/stashapp/stash/pull/2257)) * Added support for filtering scenes, images and galleries featuring favourite performers and performer age at time of production. ([#2257](https://github.com/stashapp/stash/pull/2257))
* Added support for filtering scenes with (or without) phash duplicates. ([#2257](https://github.com/stashapp/stash/pull/2257)) * Added support for filtering scenes with (or without) phash duplicates. ([#2257](https://github.com/stashapp/stash/pull/2257))
* Added support for sorting scenes by phash. ([#2257](https://github.com/stashapp/stash/pull/2257)) * Added support for sorting scenes by phash. ([#2257](https://github.com/stashapp/stash/pull/2257))

View File

@@ -9,6 +9,14 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[]; selected: GQL.SlimGalleryDataFragment[];
@@ -42,22 +50,12 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>(); const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getGalleryInput(): GQL.BulkGalleryUpdateInput { function getGalleryInput(): GQL.BulkGalleryUpdateInput {
// need to determine what we are actually setting on each gallery // need to determine what we are actually setting on each gallery
const aggregateRating = getRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getStudioId(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected); const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected);
const galleryInput: GQL.BulkGalleryUpdateInput = { const galleryInput: GQL.BulkGalleryUpdateInput = {
ids: props.selected.map((gallery) => { ids: props.selected.map((gallery) => {
@@ -65,67 +63,22 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
// if rating is undefined galleryInput.rating = getAggregateInputValue(rating, aggregateRating);
if (rating === undefined) { galleryInput.studio_id = getAggregateInputValue(
// and all galleries have the same rating, then we are unsetting the rating. studioId,
if (aggregateRating) { aggregateStudioId
// null to unset rating );
galleryInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
galleryInput.rating = rating;
}
// if studioId is undefined galleryInput.performer_ids = getAggregateInputIDs(
if (studioId === undefined) { performerMode,
// and all galleries have the same studioId, performerIds,
// then unset the studioId, otherwise ignoring studioId aggregatePerformerIds
if (aggregateStudioId) { );
// null to unset studio_id galleryInput.tag_ids = getAggregateInputIDs(
galleryInput.studio_id = null; tagMode,
} tagIds,
} else { aggregateTagIds
// if studioId is set, then we are setting it );
galleryInput.studio_id = studioId;
}
// if performerIds are empty
if (
performerMode === GQL.BulkUpdateIdMode.Set &&
(!performerIds || performerIds.length === 0)
) {
// and all galleries have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
galleryInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
} else {
// if performerIds non-empty, then we are setting them
galleryInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all galleries have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
if (organized !== undefined) { if (organized !== undefined) {
galleryInput.organized = organized; galleryInput.organized = organized;
@@ -157,85 +110,6 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false); setIsUpdating(false);
} }
function getRating(state: GQL.SlimGalleryDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.rating ?? undefined;
first = false;
} else if (ret !== gallery.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimGalleryDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery?.studio?.id;
first = false;
} else {
const studio = gallery?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimGalleryDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.performers
? gallery.performers.map((p) => p.id).sort()
: [];
first = false;
} else {
const perfIds = gallery.performers
? gallery.performers.map((p) => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimGalleryDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.tags ? gallery.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = gallery.tags ? gallery.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => { useEffect(() => {
const state = props.selected; const state = props.selected;
let updateRating: number | undefined; let updateRating: number | undefined;

View File

@@ -9,6 +9,14 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimImageDataFragment[]; selected: GQL.SlimImageDataFragment[];
@@ -42,22 +50,12 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>(); const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getImageInput(): GQL.BulkImageUpdateInput { function getImageInput(): GQL.BulkImageUpdateInput {
// need to determine what we are actually setting on each image // need to determine what we are actually setting on each image
const aggregateRating = getRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getStudioId(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected); const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected);
const imageInput: GQL.BulkImageUpdateInput = { const imageInput: GQL.BulkImageUpdateInput = {
ids: props.selected.map((image) => { ids: props.selected.map((image) => {
@@ -65,67 +63,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
// if rating is undefined imageInput.rating = getAggregateInputValue(rating, aggregateRating);
if (rating === undefined) { imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
// and all images have the same rating, then we are unsetting the rating.
if (aggregateRating) {
// null rating to unset it
imageInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
imageInput.rating = rating;
}
// if studioId is undefined imageInput.performer_ids = getAggregateInputIDs(
if (studioId === undefined) { performerMode,
// and all images have the same studioId, performerIds,
// then unset the studioId, otherwise ignoring studioId aggregatePerformerIds
if (aggregateStudioId) { );
// null studio_id to unset it imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
imageInput.studio_id = null;
}
} else {
// if studioId is set, then we are setting it
imageInput.studio_id = studioId;
}
// if performerIds are empty
if (
performerMode === GQL.BulkUpdateIdMode.Set &&
(!performerIds || performerIds.length === 0)
) {
// and all images have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
imageInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
} else {
// if performerIds non-empty, then we are setting them
imageInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all images have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
if (organized !== undefined) { if (organized !== undefined) {
imageInput.organized = organized; imageInput.organized = organized;
@@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false); setIsUpdating(false);
} }
function getRating(state: GQL.SlimImageDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.rating ?? undefined;
first = false;
} else if (ret !== image.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimImageDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image?.studio?.id;
first = false;
} else {
const studio = image?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimImageDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.performers ? image.performers.map((p) => p.id).sort() : [];
first = false;
} else {
const perfIds = image.performers
? image.performers.map((p) => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimImageDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.tags ? image.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = image.tags ? image.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => { useEffect(() => {
const state = props.selected; const state = props.selected;
let updateRating: number | undefined; let updateRating: number | undefined;

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useBulkMovieUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { Modal, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputValue,
getAggregateRating,
getAggregateStudioId,
} from "src/utils/bulkUpdate";
interface IListOperationProps {
selected: GQL.MovieDataFragment[];
onClose: (applied: boolean) => void;
}
export const EditMoviesDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>();
const [updateMovies] = useBulkMovieUpdate(getMovieInput());
const [isUpdating, setIsUpdating] = useState(false);
function getMovieInput(): GQL.BulkMovieUpdateInput {
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const movieInput: GQL.BulkMovieUpdateInput = {
ids: props.selected.map((movie) => movie.id),
director,
};
// if rating is undefined
movieInput.rating = getAggregateInputValue(rating, aggregateRating);
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
return movieInput;
}
async function onSave() {
setIsUpdating(true);
try {
await updateMovies();
Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "movies" }).toLocaleLowerCase(),
}
),
});
props.onClose(true);
} catch (e) {
Toast.error(e);
}
setIsUpdating(false);
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;
let updateStudioId: string | undefined;
let updateDirector: string | undefined;
let first = true;
state.forEach((movie: GQL.MovieDataFragment) => {
if (first) {
first = false;
updateRating = movie.rating ?? undefined;
updateStudioId = movie.studio?.id ?? undefined;
updateDirector = movie.director ?? undefined;
} else {
if (movie.rating !== updateRating) {
updateRating = undefined;
}
if (movie.studio?.id !== updateStudioId) {
updateStudioId = undefined;
}
if (movie.director !== updateDirector) {
updateDirector = undefined;
}
}
});
setRating(updateRating);
setStudioId(updateStudioId);
setDirector(updateDirector);
}, [props.selected]);
function render() {
return (
<Modal
show
icon="pencil-alt"
header="Edit Movies"
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={rating}
onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
isDisabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="director">
<Form.Label>
<FormattedMessage id="director" />
</Form.Label>
<Form.Control
className="input-control"
type="text"
value={director}
onChange={(event) => setDirector(event.currentTarget.value)}
placeholder={intl.formatMessage({ id: "director" })}
/>
</Form.Group>
</Form>
</Modal>
);
}
return render();
};

View File

@@ -6,6 +6,7 @@ import { useHistory } from "react-router-dom";
import { import {
FindMoviesQueryResult, FindMoviesQueryResult,
SlimMovieDataFragment, SlimMovieDataFragment,
MovieDataFragment,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
@@ -17,6 +18,7 @@ import {
} from "src/hooks/ListHook"; } from "src/hooks/ListHook";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { MovieCard } from "./MovieCard"; import { MovieCard } from "./MovieCard";
import { EditMoviesDialog } from "./EditMoviesDialog";
interface IMovieList { interface IMovieList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -57,6 +59,17 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
}; };
}; };
function renderEditDialog(
selectedMovies: MovieDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditMoviesDialog selected={selectedMovies} onClose={onClose} />
</>
);
}
const renderDeleteDialog = ( const renderDeleteDialog = (
selectedMovies: SlimMovieDataFragment[], selectedMovies: SlimMovieDataFragment[],
onClose: (confirmed: boolean) => void onClose: (confirmed: boolean) => void
@@ -76,6 +89,7 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
otherOperations, otherOperations,
selectable: true, selectable: true,
persistState: PersistanceLevel.ALL, persistState: PersistanceLevel.ALL,
renderEditDialog,
renderDeleteDialog, renderDeleteDialog,
filterHook, filterHook,
}); });

View File

@@ -9,6 +9,12 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregateRating,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
import { genderStrings, stringToGender } from "src/utils/gender"; import { genderStrings, stringToGender } from "src/utils/gender";
interface IListOperationProps { interface IListOperationProps {
@@ -46,20 +52,10 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>(); const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getPerformerInput(): GQL.BulkPerformerUpdateInput { function getPerformerInput(): GQL.BulkPerformerUpdateInput {
// need to determine what we are actually setting on each performer // need to determine what we are actually setting on each performer
const aggregateTagIds = getTagIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateRating = getRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const performerInput: GQL.BulkPerformerUpdateInput = { const performerInput: GQL.BulkPerformerUpdateInput = {
ids: props.selected.map((performer) => { ids: props.selected.map((performer) => {
@@ -67,33 +63,13 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
}), }),
}; };
// if rating is undefined performerInput.rating = getAggregateInputValue(rating, aggregateRating);
if (rating === undefined) {
// and all galleries have the same rating, then we are unsetting the rating.
if (aggregateRating) {
// null to unset rating
performerInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
performerInput.rating = rating;
}
// if tagIds non-empty, then we are setting them performerInput.tag_ids = getAggregateInputIDs(
if ( tagMode,
tagMode === GQL.BulkUpdateIdMode.Set && tagIds,
(!tagIds || tagIds.length === 0) aggregateTagIds
) { );
// and all performers have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
performerInput.favorite = favorite; performerInput.favorite = favorite;
performerInput.ethnicity = ethnicity; performerInput.ethnicity = ethnicity;
@@ -130,44 +106,6 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
setIsUpdating(false); setIsUpdating(false);
} }
function getTagIds(state: GQL.SlimPerformerDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((performer: GQL.SlimPerformerDataFragment) => {
if (first) {
ret = performer.tags ? performer.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = performer.tags
? performer.tags.map((t) => t.id).sort()
: [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
function getRating(state: GQL.SlimPerformerDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((performer) => {
if (first) {
ret = performer.rating ?? undefined;
first = false;
} else if (ret !== performer.rating) {
ret = undefined;
}
});
return ret;
}
useEffect(() => { useEffect(() => {
const state = props.selected; const state = props.selected;
let updateTagIds: string[] = []; let updateTagIds: string[] = [];

View File

@@ -9,6 +9,15 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "./SceneDetails/RatingStars"; import { RatingStars } from "./SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregateMovieIds,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[]; selected: GQL.SlimSceneDataFragment[];
@@ -47,23 +56,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>(); const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getSceneInput(): GQL.BulkSceneUpdateInput { function getSceneInput(): GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene // need to determine what we are actually setting on each scene
const aggregateRating = getRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getStudioId(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected); const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateMovieIds = getMovieIds(props.selected); const aggregateMovieIds = getAggregateMovieIds(props.selected);
const sceneInput: GQL.BulkSceneUpdateInput = { const sceneInput: GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => { ids: props.selected.map((scene) => {
@@ -71,82 +70,20 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
}), }),
}; };
// if rating is undefined sceneInput.rating = getAggregateInputValue(rating, aggregateRating);
if (rating === undefined) { sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
// and all scenes have the same rating, then we are unsetting the rating.
if (aggregateRating) {
// null rating unsets it
sceneInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
sceneInput.rating = rating;
}
// if studioId is undefined sceneInput.performer_ids = getAggregateInputIDs(
if (studioId === undefined) { performerMode,
// and all scenes have the same studioId, performerIds,
// then unset the studioId, otherwise ignoring studioId aggregatePerformerIds
if (aggregateStudioId) { );
// null studio_id unsets it sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
sceneInput.studio_id = null; sceneInput.movie_ids = getAggregateInputIDs(
} movieMode,
} else { movieIds,
// if studioId is set, then we are setting it aggregateMovieIds
sceneInput.studio_id = studioId; );
}
// if performerIds are empty
if (
performerMode === GQL.BulkUpdateIdMode.Set &&
(!performerIds || performerIds.length === 0)
) {
// and all scenes have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
sceneInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
} else {
// if performerIds non-empty, then we are setting them
sceneInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all scenes have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
// if movieIds non-empty, then we are setting them
if (
movieMode === GQL.BulkUpdateIdMode.Set &&
(!movieIds || movieIds.length === 0)
) {
// and all scenes have the same ids,
if (aggregateMovieIds.length > 0) {
// then unset the movieIds, otherwise ignore
sceneInput.movie_ids = makeBulkUpdateIds(movieIds || [], movieMode);
}
} else {
// if movieIds non-empty, then we are setting them
sceneInput.movie_ids = makeBulkUpdateIds(movieIds || [], movieMode);
}
if (organized !== undefined) { if (organized !== undefined) {
sceneInput.organized = organized; sceneInput.organized = organized;
@@ -172,105 +109,6 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false); setIsUpdating(false);
} }
function getRating(state: GQL.SlimSceneDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.rating ?? undefined;
first = false;
} else if (ret !== scene.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene?.studio?.id;
first = false;
} else {
const studio = scene?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.performers ? scene.performers.map((p) => p.id).sort() : [];
first = false;
} else {
const perfIds = scene.performers
? scene.performers.map((p) => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.tags ? scene.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = scene.tags ? scene.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
function getMovieIds(state: GQL.SlimSceneDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.movies ? scene.movies.map((m) => m.movie.id).sort() : [];
first = false;
} else {
const mIds = scene.movies
? scene.movies.map((m) => m.movie.id).sort()
: [];
if (!_.isEqual(ret, mIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => { useEffect(() => {
const state = props.selected; const state = props.selected;
let updateRating: number | undefined; let updateRating: number | undefined;

View File

@@ -659,6 +659,14 @@ export const useMovieUpdate = () =>
update: deleteCache(movieMutationImpactedQueries), update: deleteCache(movieMutationImpactedQueries),
}); });
export const useBulkMovieUpdate = (input: GQL.BulkMovieUpdateInput) =>
GQL.useBulkMovieUpdateMutation({
variables: {
input,
},
update: deleteCache(movieMutationImpactedQueries),
});
export const useMovieDestroy = (input: GQL.MovieDestroyInput) => export const useMovieDestroy = (input: GQL.MovieDestroyInput) =>
GQL.useMovieDestroyMutation({ GQL.useMovieDestroyMutation({
variables: input, variables: input,

View File

@@ -0,0 +1,175 @@
import * as GQL from "src/core/generated-graphql";
import _ from "lodash";
interface IHasRating {
rating?: GQL.Maybe<number> | undefined;
}
export function getAggregateRating(state: IHasRating[]) {
let ret: number | undefined;
let first = true;
state.forEach((o) => {
if (first) {
ret = o.rating ?? undefined;
first = false;
} else if (ret !== o.rating) {
ret = undefined;
}
});
return ret;
}
interface IHasID {
id: string;
}
interface IHasStudio {
studio?: GQL.Maybe<IHasID> | undefined;
}
export function getAggregateStudioId(state: IHasStudio[]) {
let ret: string | undefined;
let first = true;
state.forEach((o) => {
if (first) {
ret = o?.studio?.id;
first = false;
} else {
const studio = o?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
interface IHasPerformers {
performers: IHasID[];
}
export function getAggregatePerformerIds(state: IHasPerformers[]) {
let ret: string[] = [];
let first = true;
state.forEach((o) => {
if (first) {
ret = o.performers ? o.performers.map((p) => p.id).sort() : [];
first = false;
} else {
const perfIds = o.performers ? o.performers.map((p) => p.id).sort() : [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
interface IHasTags {
tags: IHasID[];
}
export function getAggregateTagIds(state: IHasTags[]) {
let ret: string[] = [];
let first = true;
state.forEach((o) => {
if (first) {
ret = o.tags ? o.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = o.tags ? o.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
interface IMovie {
movie: IHasID;
}
interface IHasMovies {
movies: IMovie[];
}
export function getAggregateMovieIds(state: IHasMovies[]) {
let ret: string[] = [];
let first = true;
state.forEach((o) => {
if (first) {
ret = o.movies ? o.movies.map((m) => m.movie.id).sort() : [];
first = false;
} else {
const mIds = o.movies ? o.movies.map((m) => m.movie.id).sort() : [];
if (!_.isEqual(ret, mIds)) {
ret = [];
}
}
});
return ret;
}
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
export function getAggregateInputValue<V>(
inputValue: V | null | undefined,
aggregateValue: V | null | undefined
) {
if (inputValue === undefined) {
// and all objects have the same value, then we are unsetting the value.
if (aggregateValue !== undefined) {
// null to unset rating
return null;
}
// otherwise not setting the rating
return undefined;
} else {
// if value is set, then we are setting the value for all
return inputValue;
}
}
export function getAggregateInputIDs(
mode: GQL.BulkUpdateIdMode,
inputIds: string[] | undefined,
aggregateIds: string[]
) {
if (
mode === GQL.BulkUpdateIdMode.Set &&
(!inputIds || inputIds.length === 0)
) {
// and all scenes have the same ids,
if (aggregateIds.length > 0) {
// then unset the performerIds, otherwise ignore
return makeBulkUpdateIds(inputIds || [], mode);
}
} else {
// if performerIds non-empty, then we are setting them
return makeBulkUpdateIds(inputIds || [], mode);
}
return undefined;
}