diff --git a/graphql/documents/mutations/movie.graphql b/graphql/documents/mutations/movie.graphql index e1236b9dd..375b3d239 100644 --- a/graphql/documents/mutations/movie.graphql +++ b/graphql/documents/mutations/movie.graphql @@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) { } } +mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) { + bulkMovieUpdate(input: $input) { + ...MovieData + } +} + mutation MovieDestroy($id: ID!) { movieDestroy(input: { id: $id }) } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 5139c66c3..9b5bf6ed7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -224,6 +224,7 @@ type Mutation { movieUpdate(input: MovieUpdateInput!): Movie movieDestroy(input: MovieDestroyInput!): Boolean! moviesDestroy(ids: [ID!]!): Boolean! + bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!] tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 104c68176..3d100e141 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -54,6 +54,14 @@ input MovieUpdateInput { back_image: String } +input BulkMovieUpdateInput { + clientMutationId: String + ids: [ID!] + rating: Int + studio_id: ID + director: String +} + input MovieDestroyInput { id: ID! } diff --git a/pkg/api/resolver_mutation_movie.go b/pkg/api/resolver_mutation_movie.go index 88769f6d3..59413f148 100644 --- a/pkg/api/resolver_mutation_movie.go +++ b/pkg/api/resolver_mutation_movie.go @@ -3,6 +3,7 @@ package api import ( "context" "database/sql" + "fmt" "strconv" "time" @@ -220,6 +221,71 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp 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) { id, err := strconv.Atoi(input.ID) if err != nil { diff --git a/ui/v2.5/src/components/Changelog/versions/v0130.md b/ui/v2.5/src/components/Changelog/versions/v0130.md index c5a4baeeb..4bfadafb7 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0130.md +++ b/ui/v2.5/src/components/Changelog/versions/v0130.md @@ -1,4 +1,5 @@ ### ✨ 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 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)) diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 88e2bd0be..9bd5bdb72 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -9,6 +9,14 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregatePerformerIds, + getAggregateRating, + getAggregateStudioId, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; @@ -42,22 +50,12 @@ export const EditGalleriesDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getGalleryInput(): GQL.BulkGalleryUpdateInput { // need to determine what we are actually setting on each gallery - const aggregateRating = getRating(props.selected); - const aggregateStudioId = getStudioId(props.selected); - const aggregatePerformerIds = getPerformerIds(props.selected); - const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregatePerformerIds = getAggregatePerformerIds(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const galleryInput: GQL.BulkGalleryUpdateInput = { ids: props.selected.map((gallery) => { @@ -65,67 +63,22 @@ export const EditGalleriesDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // and all galleries have the same rating, then we are unsetting the rating. - if (aggregateRating) { - // 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; - } + galleryInput.rating = getAggregateInputValue(rating, aggregateRating); + galleryInput.studio_id = getAggregateInputValue( + studioId, + aggregateStudioId + ); - // if studioId is undefined - if (studioId === undefined) { - // and all galleries have the same studioId, - // then unset the studioId, otherwise ignoring studioId - if (aggregateStudioId) { - // null to unset studio_id - galleryInput.studio_id = null; - } - } else { - // 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); - } + galleryInput.performer_ids = getAggregateInputIDs( + performerMode, + performerIds, + aggregatePerformerIds + ); + galleryInput.tag_ids = getAggregateInputIDs( + tagMode, + tagIds, + aggregateTagIds + ); if (organized !== undefined) { galleryInput.organized = organized; @@ -157,85 +110,6 @@ export const EditGalleriesDialog: React.FC = ( 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(() => { const state = props.selected; let updateRating: number | undefined; diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 237f8f0fc..d1923453d 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -9,6 +9,14 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregatePerformerIds, + getAggregateRating, + getAggregateStudioId, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; interface IListOperationProps { selected: GQL.SlimImageDataFragment[]; @@ -42,22 +50,12 @@ export const EditImagesDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getImageInput(): GQL.BulkImageUpdateInput { // need to determine what we are actually setting on each image - const aggregateRating = getRating(props.selected); - const aggregateStudioId = getStudioId(props.selected); - const aggregatePerformerIds = getPerformerIds(props.selected); - const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregatePerformerIds = getAggregatePerformerIds(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const imageInput: GQL.BulkImageUpdateInput = { ids: props.selected.map((image) => { @@ -65,67 +63,15 @@ export const EditImagesDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // 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; - } + imageInput.rating = getAggregateInputValue(rating, aggregateRating); + imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - // if studioId is undefined - if (studioId === undefined) { - // and all images have the same studioId, - // then unset the studioId, otherwise ignoring studioId - if (aggregateStudioId) { - // null studio_id to unset it - 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); - } + imageInput.performer_ids = getAggregateInputIDs( + performerMode, + performerIds, + aggregatePerformerIds + ); + imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); if (organized !== undefined) { imageInput.organized = organized; @@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC = ( 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(() => { const state = props.selected; let updateRating: number | undefined; diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx new file mode 100644 index 000000000..d93a82217 --- /dev/null +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -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 = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + const [rating, setRating] = useState(); + const [studioId, setStudioId] = useState(); + const [director, setDirector] = useState(); + + 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 ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > +
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "rating" }), + })} + + setRating(value)} + disabled={isUpdating} + /> + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "studio" }), + })} + + + setStudioId(items.length > 0 ? items[0]?.id : undefined) + } + ids={studioId ? [studioId] : []} + isDisabled={isUpdating} + /> + + + + + + + setDirector(event.currentTarget.value)} + placeholder={intl.formatMessage({ id: "director" })} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index ac5b3a5bc..fc7ed0b1c 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -6,6 +6,7 @@ import { useHistory } from "react-router-dom"; import { FindMoviesQueryResult, SlimMovieDataFragment, + MovieDataFragment, } from "src/core/generated-graphql"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; @@ -17,6 +18,7 @@ import { } from "src/hooks/ListHook"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { MovieCard } from "./MovieCard"; +import { EditMoviesDialog } from "./EditMoviesDialog"; interface IMovieList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -57,6 +59,17 @@ export const MovieList: React.FC = ({ filterHook }) => { }; }; + function renderEditDialog( + selectedMovies: MovieDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + <> + + + ); + } + const renderDeleteDialog = ( selectedMovies: SlimMovieDataFragment[], onClose: (confirmed: boolean) => void @@ -76,6 +89,7 @@ export const MovieList: React.FC = ({ filterHook }) => { otherOperations, selectable: true, persistState: PersistanceLevel.ALL, + renderEditDialog, renderDeleteDialog, filterHook, }); diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index de2e0cd49..83d135c75 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -9,6 +9,12 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregateRating, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; import { genderStrings, stringToGender } from "src/utils/gender"; interface IListOperationProps { @@ -46,20 +52,10 @@ export const EditPerformersDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getPerformerInput(): GQL.BulkPerformerUpdateInput { // need to determine what we are actually setting on each performer - const aggregateTagIds = getTagIds(props.selected); - const aggregateRating = getRating(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); const performerInput: GQL.BulkPerformerUpdateInput = { ids: props.selected.map((performer) => { @@ -67,33 +63,13 @@ export const EditPerformersDialog: React.FC = ( }), }; - // if rating is undefined - 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; - } + performerInput.rating = getAggregateInputValue(rating, aggregateRating); - // if tagIds non-empty, then we are setting them - if ( - tagMode === GQL.BulkUpdateIdMode.Set && - (!tagIds || tagIds.length === 0) - ) { - // 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.tag_ids = getAggregateInputIDs( + tagMode, + tagIds, + aggregateTagIds + ); performerInput.favorite = favorite; performerInput.ethnicity = ethnicity; @@ -130,44 +106,6 @@ export const EditPerformersDialog: React.FC = ( 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(() => { const state = props.selected; let updateTagIds: string[] = []; diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 72237531b..acbe4e77d 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -9,6 +9,15 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "./SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregateMovieIds, + getAggregatePerformerIds, + getAggregateRating, + getAggregateStudioId, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; @@ -47,23 +56,13 @@ export const EditScenesDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getSceneInput(): GQL.BulkSceneUpdateInput { // need to determine what we are actually setting on each scene - const aggregateRating = getRating(props.selected); - const aggregateStudioId = getStudioId(props.selected); - const aggregatePerformerIds = getPerformerIds(props.selected); - const aggregateTagIds = getTagIds(props.selected); - const aggregateMovieIds = getMovieIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregatePerformerIds = getAggregatePerformerIds(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateMovieIds = getAggregateMovieIds(props.selected); const sceneInput: GQL.BulkSceneUpdateInput = { ids: props.selected.map((scene) => { @@ -71,82 +70,20 @@ export const EditScenesDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // 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; - } + sceneInput.rating = getAggregateInputValue(rating, aggregateRating); + sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - // if studioId is undefined - if (studioId === undefined) { - // and all scenes have the same studioId, - // then unset the studioId, otherwise ignoring studioId - if (aggregateStudioId) { - // null studio_id unsets it - sceneInput.studio_id = null; - } - } else { - // if studioId is set, then we are setting it - 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); - } + sceneInput.performer_ids = getAggregateInputIDs( + performerMode, + performerIds, + aggregatePerformerIds + ); + sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + sceneInput.movie_ids = getAggregateInputIDs( + movieMode, + movieIds, + aggregateMovieIds + ); if (organized !== undefined) { sceneInput.organized = organized; @@ -172,105 +109,6 @@ export const EditScenesDialog: React.FC = ( 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(() => { const state = props.selected; let updateRating: number | undefined; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 7774f894f..9d87b6497 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -659,6 +659,14 @@ export const useMovieUpdate = () => update: deleteCache(movieMutationImpactedQueries), }); +export const useBulkMovieUpdate = (input: GQL.BulkMovieUpdateInput) => + GQL.useBulkMovieUpdateMutation({ + variables: { + input, + }, + update: deleteCache(movieMutationImpactedQueries), + }); + export const useMovieDestroy = (input: GQL.MovieDestroyInput) => GQL.useMovieDestroyMutation({ variables: input, diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts new file mode 100644 index 000000000..9624e5605 --- /dev/null +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -0,0 +1,175 @@ +import * as GQL from "src/core/generated-graphql"; +import _ from "lodash"; + +interface IHasRating { + rating?: GQL.Maybe | 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 | 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( + 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; +}