diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index df19c8e8c..80c38d109 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -39,8 +39,8 @@ mutation BulkSceneUpdate( $rating: Int, $studio_id: ID, $gallery_id: ID, - $performer_ids: [ID!], - $tag_ids: [ID!]) { + $performer_ids: BulkUpdateIds, + $tag_ids: BulkUpdateIds) { bulkSceneUpdate(input: { ids: $ids, diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 355e361a1..e6b5a0e03 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -68,6 +68,17 @@ input SceneUpdateInput { cover_image: String } +enum BulkUpdateIdMode { + SET + ADD + REMOVE +} + +input BulkUpdateIds { + ids: [ID!] + mode: BulkUpdateIdMode! +} + input BulkSceneUpdateInput { clientMutationId: String ids: [ID!] @@ -78,8 +89,8 @@ input BulkSceneUpdateInput { rating: Int studio_id: ID gallery_id: ID - performer_ids: [ID!] - tag_ids: [ID!] + performer_ids: BulkUpdateIds + tag_ids: BulkUpdateIds } input SceneDestroyInput { diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index ac920954b..e170563b1 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -269,9 +269,14 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul // Save the performers if wasFieldIncluded(ctx, "performer_ids") { + performerIDs, err := adjustScenePerformerIDs(tx, sceneID, *input.PerformerIds) + if err != nil { + _ = tx.Rollback() + return nil, err + } + var performerJoins []models.PerformersScenes - for _, pid := range input.PerformerIds { - performerID, _ := strconv.Atoi(pid) + for _, performerID := range performerIDs { performerJoin := models.PerformersScenes{ PerformerID: performerID, SceneID: sceneID, @@ -286,9 +291,14 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul // Save the tags if wasFieldIncluded(ctx, "tag_ids") { + tagIDs, err := adjustSceneTagIDs(tx, sceneID, *input.TagIds) + if err != nil { + _ = tx.Rollback() + return nil, err + } + var tagJoins []models.ScenesTags - for _, tid := range input.TagIds { - tagID, _ := strconv.Atoi(tid) + for _, tagID := range tagIDs { tagJoin := models.ScenesTags{ SceneID: sceneID, TagID: tagID, @@ -310,6 +320,72 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul return ret, nil } +func adjustIDs(existingIDs []int, updateIDs models.BulkUpdateIds) []int { + for _, idStr := range updateIDs.Ids { + id, _ := strconv.Atoi(idStr) + + // look for the id in the list + foundExisting := false + for idx, existingID := range existingIDs { + if existingID == id { + if updateIDs.Mode == models.BulkUpdateIDModeRemove { + // remove from the list + existingIDs = append(existingIDs[:idx], existingIDs[idx+1:]...) + } + + foundExisting = true + break + } + } + + if !foundExisting && updateIDs.Mode != models.BulkUpdateIDModeRemove { + existingIDs = append(existingIDs, id) + } + } + + return existingIDs +} + +func adjustScenePerformerIDs(tx *sqlx.Tx, sceneID int, ids models.BulkUpdateIds) ([]int, error) { + var ret []int + + jqb := models.NewJoinsQueryBuilder() + if ids.Mode == models.BulkUpdateIDModeAdd || ids.Mode == models.BulkUpdateIDModeRemove { + // adding to the joins + performerJoins, err := jqb.GetScenePerformers(sceneID, tx) + + if err != nil { + return nil, err + } + + for _, join := range performerJoins { + ret = append(ret, join.PerformerID) + } + } + + return adjustIDs(ret, ids), nil +} + +func adjustSceneTagIDs(tx *sqlx.Tx, sceneID int, ids models.BulkUpdateIds) ([]int, error) { + var ret []int + + jqb := models.NewJoinsQueryBuilder() + if ids.Mode == models.BulkUpdateIDModeAdd || ids.Mode == models.BulkUpdateIDModeRemove { + // adding to the joins + tagJoins, err := jqb.GetSceneTags(sceneID, tx) + + if err != nil { + return nil, err + } + + for _, join := range tagJoins { + ret = append(ret, join.TagID) + } + } + + return adjustIDs(ret, ids), nil +} + func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) { qb := models.NewSceneQueryBuilder() tx := database.DB.MustBeginTx(ctx, nil) diff --git a/ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx b/ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx index b39d401b6..adfd56f97 100644 --- a/ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx +++ b/ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx @@ -4,11 +4,11 @@ import _ from "lodash"; import { StashService } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { - FilterSelect, StudioSelect, LoadingIndicator } from "src/components/Shared"; import { useToast } from "src/hooks"; +import MultiSet from "../Shared/MultiSet"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; @@ -21,7 +21,9 @@ export const SceneSelectedOptions: React.FC = ( const Toast = useToast(); const [rating, setRating] = useState(""); const [studioId, setStudioId] = useState(); + const [performerMode, setPerformerMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [performerIds, setPerformerIds] = useState(); + const [tagMode, setTagMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [tagIds, setTagIds] = useState(); const [updateScenes] = StashService.useBulkSceneUpdate(getSceneInput()); @@ -29,6 +31,13 @@ export const SceneSelectedOptions: React.FC = ( // Network state const [isLoading, setIsLoading] = useState(true); + 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); @@ -69,27 +78,27 @@ export const SceneSelectedOptions: React.FC = ( } // if performerIds are empty - if (!performerIds || performerIds.length === 0) { + 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 = performerIds; + sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode); } } else { // if performerIds non-empty, then we are setting them - sceneInput.performer_ids = performerIds; + sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode); } // if tagIds non-empty, then we are setting them - if (!tagIds || tagIds.length === 0) { + 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 = tagIds; + sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } } else { // if tagIds non-empty, then we are setting them - sceneInput.tag_ids = tagIds; + sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } return sceneInput; @@ -222,21 +231,31 @@ export const SceneSelectedOptions: React.FC = ( setRating(updateRating); setStudioId(updateStudioID); - setPerformerIds(updatePerformerIds); - setTagIds(updateTagIds); + if (performerMode === GQL.BulkUpdateIdMode.Set) { + setPerformerIds(updatePerformerIds); + } + + if (tagMode === GQL.BulkUpdateIdMode.Set) { + setTagIds(updateTagIds); + } + setIsLoading(false); - }, [props.selected]); + }, [props.selected, performerMode, tagMode]); function renderMultiSelect( type: "performers" | "tags", ids: string[] | undefined ) { + let mode = GQL.BulkUpdateIdMode.Add; + switch (type) { + case "performers": mode = performerMode; break; + case "tags": mode = tagMode; break; + } + return ( - { + onUpdate={items => { const itemIDs = items.map(i => i.id); switch (type) { case "performers": @@ -247,7 +266,14 @@ export const SceneSelectedOptions: React.FC = ( break; } }} + onSetMode={(mode) => { + switch (type) { + case "performers": setPerformerMode(mode); break; + case "tags": setTagMode(mode); break; + } + }} ids={ids ?? []} + mode={mode} /> ); } @@ -264,6 +290,7 @@ export const SceneSelectedOptions: React.FC = ( Rating ) => setRating(event.currentTarget.value) diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx new file mode 100644 index 000000000..170efb63e --- /dev/null +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -0,0 +1,83 @@ +import * as React from "react"; + +import * as GQL from "src/core/generated-graphql"; +import { FilterSelect } from "./Select"; +import { Button, InputGroup } from "react-bootstrap"; +import { Icon } from "src/components/Shared"; + +type ValidTypes = + | GQL.SlimPerformerDataFragment + | GQL.Tag + | GQL.SlimStudioDataFragment; + +interface IMultiSetProps { + type: "performers" | "studios" | "tags"; + ids?: string[]; + mode: GQL.BulkUpdateIdMode; + onUpdate: (items: ValidTypes[]) => void; + onSetMode: (mode: GQL.BulkUpdateIdMode) => void; +} + +const MultiSet: React.FunctionComponent = (props: IMultiSetProps) => { + function onUpdate(items: ValidTypes[]) { + props.onUpdate(items); + } + + function getModeIcon() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return "pencil-alt"; + case GQL.BulkUpdateIdMode.Add: + return "plus"; + case GQL.BulkUpdateIdMode.Remove: + return "times"; + } + } + + function getModeText() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return "Set"; + case GQL.BulkUpdateIdMode.Add: + return "Add"; + case GQL.BulkUpdateIdMode.Remove: + return "Remove"; + } + } + + function nextMode() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return GQL.BulkUpdateIdMode.Add; + case GQL.BulkUpdateIdMode.Add: + return GQL.BulkUpdateIdMode.Remove; + case GQL.BulkUpdateIdMode.Remove: + return GQL.BulkUpdateIdMode.Set; + } + } + + return ( + + + + + + + + ); +}; + +export default MultiSet; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index dcb227ff7..19f2bfdaf 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -68,3 +68,10 @@ padding: 0; } } + +.multi-set > div.input-group-prepend + div { + position: relative; + flex: 1 1; + min-width: 0; + margin-bottom: 0; +} \ No newline at end of file diff --git a/ui/v2/src/components/scenes/SceneSelectedOptions.tsx b/ui/v2/src/components/scenes/SceneSelectedOptions.tsx index 35a28d2eb..c47af49a3 100644 --- a/ui/v2/src/components/scenes/SceneSelectedOptions.tsx +++ b/ui/v2/src/components/scenes/SceneSelectedOptions.tsx @@ -8,11 +8,11 @@ import { } from "@blueprintjs/core"; import React, { FunctionComponent, useEffect, useState } from "react"; import { FilterSelect } from "../select/FilterSelect"; -import { FilterMultiSelect } from "../select/FilterMultiSelect"; import { StashService } from "../../core/StashService"; import * as GQL from "../../core/generated-graphql"; import { ErrorUtils } from "../../utils/errors"; import { ToastUtils } from "../../utils/toasts"; +import { FilterMultiSet } from "../select/FilterMultiSet"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[], @@ -22,7 +22,9 @@ interface IListOperationProps { export const SceneSelectedOptions: FunctionComponent = (props: IListOperationProps) => { const [rating, setRating] = useState(""); const [studioId, setStudioId] = useState(undefined); + const [performerMode, setPerformerMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [performerIds, setPerformerIds] = useState(undefined); + const [tagMode, setTagMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [tagIds, setTagIds] = useState(undefined); const updateScenes = StashService.useBulkSceneUpdate(getSceneInput()); @@ -30,6 +32,13 @@ export const SceneSelectedOptions: FunctionComponent = (pro // Network state const [isLoading, setIsLoading] = useState(false); + 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 var aggregateRating = getRating(props.selected); @@ -70,27 +79,27 @@ export const SceneSelectedOptions: FunctionComponent = (pro } // if performerIds are empty - if (!performerIds || performerIds.length === 0) { + 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 = performerIds; + sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode); } } else { // if performerIds non-empty, then we are setting them - sceneInput.performer_ids = performerIds; + sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode); } // if tagIds non-empty, then we are setting them - if (!tagIds || tagIds.length === 0) { + 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 = tagIds; + sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } } else { // if tagIds non-empty, then we are setting them - sceneInput.tag_ids = tagIds; + sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); } return sceneInput; @@ -232,17 +241,28 @@ export const SceneSelectedOptions: FunctionComponent = (pro setRating(rating); setStudioId(studioId); - setPerformerIds(performerIds); - setTagIds(tagIds); + if (performerMode == GQL.BulkUpdateIdMode.Set) { + setPerformerIds(performerIds); + } + + if (tagMode == GQL.BulkUpdateIdMode.Set) { + setTagIds(tagIds); + } } useEffect(() => { updateScenesEditState(props.selected); - }, [props.selected]); + }, [props.selected, performerMode, tagMode]); function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) { + let mode = GQL.BulkUpdateIdMode.Add; + switch (type) { + case "performers": mode = performerMode; break; + case "tags": mode = tagMode; break; + } + return ( - { const ids = items.map((i) => i.id); @@ -251,7 +271,14 @@ export const SceneSelectedOptions: FunctionComponent = (pro case "tags": setTagIds(ids); break; } }} + onSetMode={(mode) => { + switch (type) { + case "performers": setPerformerMode(mode); break; + case "tags": setTagMode(mode); break; + } + }} initialIds={initialIds} + mode={mode} /> ); } diff --git a/ui/v2/src/components/select/FilterMultiSet.tsx b/ui/v2/src/components/select/FilterMultiSet.tsx new file mode 100644 index 000000000..07ed6769c --- /dev/null +++ b/ui/v2/src/components/select/FilterMultiSet.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; + +import { ControlGroup, Button } from "@blueprintjs/core"; +import * as GQL from "../../core/generated-graphql"; +import { FilterMultiSelect } from "./FilterMultiSelect"; + +type ValidTypes = + GQL.AllPerformersForFilterAllPerformers | + GQL.AllTagsForFilterAllTags | + GQL.AllMoviesForFilterAllMovies | + GQL.AllStudiosForFilterAllStudios; + +interface IFilterMultiSetProps { + type: "performers" | "studios" | "movies" | "tags"; + initialIds?: string[]; + mode: GQL.BulkUpdateIdMode; + onUpdate: (items: ValidTypes[]) => void; + onSetMode: (mode: GQL.BulkUpdateIdMode) => void; +} + +export const FilterMultiSet: React.FunctionComponent = (props: IFilterMultiSetProps) => { + function onUpdate(items: ValidTypes[]) { + props.onUpdate(items); + } + + function getModeIcon() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return "edit"; + case GQL.BulkUpdateIdMode.Add: + return "plus"; + case GQL.BulkUpdateIdMode.Remove: + return "cross"; + } + } + + function getModeText() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return "Set"; + case GQL.BulkUpdateIdMode.Add: + return "Add"; + case GQL.BulkUpdateIdMode.Remove: + return "Remove"; + } + } + + function nextMode() { + switch(props.mode) { + case GQL.BulkUpdateIdMode.Set: + return GQL.BulkUpdateIdMode.Add; + case GQL.BulkUpdateIdMode.Add: + return GQL.BulkUpdateIdMode.Remove; + case GQL.BulkUpdateIdMode.Remove: + return GQL.BulkUpdateIdMode.Set; + } + } + + return ( + +