diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index 43231808b..2f54270ec 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -26,6 +26,34 @@ mutation SceneUpdate( } } +mutation BulkSceneUpdate( + $ids: [ID!] = [], + $title: String, + $details: String, + $url: String, + $date: String, + $rating: Int, + $studio_id: ID, + $gallery_id: ID, + $performer_ids: [ID!], + $tag_ids: [ID!]) { + + bulkSceneUpdate(input: { + ids: $ids, + title: $title, + details: $details, + url: $url, + date: $date, + rating: $rating, + studio_id: $studio_id, + gallery_id: $gallery_id, + performer_ids: $performer_ids, + tag_ids: $tag_ids + }) { + ...SceneData + } +} + mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boolean) { sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated}) } \ No newline at end of file diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index b8af1c639..c6c9ca409 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -77,6 +77,7 @@ type Query { type Mutation { sceneUpdate(input: SceneUpdateInput!): Scene + bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index a23b2d13b..18bd19e32 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -53,6 +53,20 @@ input SceneUpdateInput { tag_ids: [ID!] } +input BulkSceneUpdateInput { + clientMutationId: String + ids: [ID!] + title: String + details: String + url: String + date: String + rating: Int + studio_id: ID + gallery_id: ID + performer_ids: [ID!] + tag_ids: [ID!] +} + input SceneDestroyInput { id: ID! delete_file: Boolean diff --git a/pkg/api/resolver.go b/pkg/api/resolver.go index 2744cb322..1fa3fda84 100644 --- a/pkg/api/resolver.go +++ b/pkg/api/resolver.go @@ -5,6 +5,8 @@ import ( "sort" "strconv" + "github.com/99designs/gqlgen/graphql" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" ) @@ -165,3 +167,13 @@ func (r *queryResolver) ScrapeFreeones(ctx context.Context, performer_name strin func (r *queryResolver) ScrapeFreeonesPerformerList(ctx context.Context, query string) ([]string, error) { return scraper.GetPerformerNames(query) } + +// wasFieldIncluded returns true if the given field was included in the request. +// Slices are unmarshalled to empty slices even if the field was omitted. This +// method determines if it was omitted altogether. +func wasFieldIncluded(ctx context.Context, field string) bool { + rctx := graphql.GetRequestContext(ctx) + + _, ret := rctx.Variables[field] + return ret +} diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 66ccf4af6..fe724a775 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -119,6 +119,121 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp return scene, nil } +func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.BulkSceneUpdateInput) ([]*models.Scene, error) { + // Populate scene from the input + updatedTime := time.Now() + + // Start the transaction and save the scene marker + tx := database.DB.MustBeginTx(ctx, nil) + qb := models.NewSceneQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + + updatedScene := models.ScenePartial{ + UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + } + if input.Title != nil { + updatedScene.Title = &sql.NullString{String: *input.Title, Valid: true} + } + if input.Details != nil { + updatedScene.Details = &sql.NullString{String: *input.Details, Valid: true} + } + if input.URL != nil { + updatedScene.URL = &sql.NullString{String: *input.URL, Valid: true} + } + if input.Date != nil { + updatedScene.Date = &models.SQLiteDate{String: *input.Date, Valid: true} + } + if input.Rating != nil { + // a rating of 0 means unset the rating + if *input.Rating == 0 { + updatedScene.Rating = &sql.NullInt64{Int64: 0, Valid: false} + } else { + updatedScene.Rating = &sql.NullInt64{Int64: int64(*input.Rating), Valid: true} + } + } + if input.StudioID != nil { + // empty string means unset the studio + if *input.StudioID == "" { + updatedScene.StudioID = &sql.NullInt64{Int64: 0, Valid: false} + } else { + studioID, _ := strconv.ParseInt(*input.StudioID, 10, 64) + updatedScene.StudioID = &sql.NullInt64{Int64: studioID, Valid: true} + } + } + + ret := []*models.Scene{} + + for _, sceneIDStr := range input.Ids { + sceneID, _ := strconv.Atoi(sceneIDStr) + updatedScene.ID = sceneID + + scene, err := qb.Update(updatedScene, tx) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + ret = append(ret, scene) + + if input.GalleryID != nil { + // Save the gallery + galleryID, _ := strconv.Atoi(*input.GalleryID) + updatedGallery := models.Gallery{ + ID: galleryID, + SceneID: sql.NullInt64{Int64: int64(sceneID), Valid: true}, + UpdatedAt: models.SQLiteTimestamp{Timestamp: updatedTime}, + } + gqb := models.NewGalleryQueryBuilder() + _, err := gqb.Update(updatedGallery, tx) + if err != nil { + _ = tx.Rollback() + return nil, err + } + } + + // Save the performers + if wasFieldIncluded(ctx, "performer_ids") { + var performerJoins []models.PerformersScenes + for _, pid := range input.PerformerIds { + performerID, _ := strconv.Atoi(pid) + performerJoin := models.PerformersScenes{ + PerformerID: performerID, + SceneID: sceneID, + } + performerJoins = append(performerJoins, performerJoin) + } + if err := jqb.UpdatePerformersScenes(sceneID, performerJoins, tx); err != nil { + _ = tx.Rollback() + return nil, err + } + } + + // Save the tags + if wasFieldIncluded(ctx, "tag_ids") { + var tagJoins []models.ScenesTags + for _, tid := range input.TagIds { + tagID, _ := strconv.Atoi(tid) + tagJoin := models.ScenesTags{ + SceneID: sceneID, + TagID: tagID, + } + tagJoins = append(tagJoins, tagJoin) + } + if err := jqb.UpdateScenesTags(sceneID, tagJoins, tx); err != nil { + _ = tx.Rollback() + return nil, err + } + } + } + + // Commit + if err := tx.Commit(); err != nil { + return nil, err + } + + return ret, 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/src/components/list/ListFilter.tsx b/ui/v2/src/components/list/ListFilter.tsx index 25075d06c..1aa97cf5a 100644 --- a/ui/v2/src/components/list/ListFilter.tsx +++ b/ui/v2/src/components/list/ListFilter.tsx @@ -25,6 +25,8 @@ interface IListFilterProps { onChangeDisplayMode: (displayMode: DisplayMode) => void; onAddCriterion: (criterion: Criterion, oldId?: string) => void; onRemoveCriterion: (criterion: Criterion) => void; + onSelectAll?: () => void; + onSelectNone?: () => void; filter: ListFilterModel; } @@ -134,6 +136,39 @@ export const ListFilter: FunctionComponent = (props: IListFilt )); } + function onSelectAll() { + if (props.onSelectAll) { + props.onSelectAll(); + } + } + + function onSelectNone() { + if (props.onSelectNone) { + props.onSelectNone(); + } + } + + function renderSelectAll() { + if (props.onSelectAll) { + return + + + + ); + } + + return render(); +}; diff --git a/ui/v2/src/components/select/FilterMultiSelect.tsx b/ui/v2/src/components/select/FilterMultiSelect.tsx index 99434568b..a5c8d7462 100644 --- a/ui/v2/src/components/select/FilterMultiSelect.tsx +++ b/ui/v2/src/components/select/FilterMultiSelect.tsx @@ -24,13 +24,21 @@ interface IProps extends HTMLInputProps, Partial> } export const FilterMultiSelect: React.FunctionComponent = (props: IProps) => { - let items: ValidTypes[]; - let InternalMultiSelect: new (props: IMultiSelectProps) => MultiSelect; - var createNewFunc = undefined; + let MultiSelectImpl = getMultiSelectImpl(); + let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect(); + const data = MultiSelectImpl.getData(); + const [selectedItems, setSelectedItems] = React.useState([]); + const [items, setItems] = React.useState([]); const [newTagName, setNewTagName] = React.useState(""); const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput); + React.useEffect(() => { + if (!!data) { + MultiSelectImpl.translateData(); + } + }, [data]); + function getTagInput() { const tagInput: Partial = { name: newTagName }; return tagInput; @@ -42,8 +50,10 @@ export const FilterMultiSelect: React.FunctionComponent = (props: IProps try { created = await createTag(); + items.push(created.data.tagCreate); + setItems(items.slice()); addSelectedItem(created.data.tagCreate); - + ToastUtils.success("Created tag"); } catch (e) { ErrorUtils.handle(e); @@ -76,43 +86,50 @@ export const FilterMultiSelect: React.FunctionComponent = (props: IProps ); } - switch (props.type) { - case "performers": { - const { data } = StashService.useAllPerformersForFilter(); - items = !!data && !!data.allPerformers ? data.allPerformers : []; - InternalMultiSelect = InternalPerformerMultiSelect; - break; - } - case "studios": { - const { data } = StashService.useAllStudiosForFilter(); - items = !!data && !!data.allStudios ? data.allStudios : []; - InternalMultiSelect = InternalStudioMultiSelect; - break; - } - case "tags": { - const { data } = StashService.useAllTagsForFilter(); - items = !!data && !!data.allTags ? data.allTags : []; - InternalMultiSelect = InternalTagMultiSelect; - createNewFunc = createNewTag; - break; - } - default: { - console.error("Unhandled case in FilterMultiSelect"); - return <>Unhandled case in FilterMultiSelect; - } - } - - /* eslint-disable react-hooks/rules-of-hooks */ - const [selectedItems, setSelectedItems] = React.useState([]); - const [isInitialized, setIsInitialized] = React.useState(false); - /* eslint-enable */ - - if (!!props.initialIds && selectedItems.length === 0 && !isInitialized) { - const initialItems = items.filter((item) => props.initialIds!.includes(item.id)); - if (initialItems.length > 0) { + React.useEffect(() => { + if (!!props.initialIds && !!items) { + const initialItems = items.filter((item) => props.initialIds!.includes(item.id)); setSelectedItems(initialItems); - setIsInitialized(true); } + }, [props.initialIds, items]); + + function getMultiSelectImpl() { + let getInternalMultiSelect: () => new (props: IMultiSelectProps) => MultiSelect; + let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllTagsForFilterQuery | undefined; + let translateData: () => void; + let createNewObject: ((query : string) => void) | undefined = undefined; + + switch (props.type) { + case "performers": { + getInternalMultiSelect = () => { return InternalPerformerMultiSelect; }; + getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; } + translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); }; + break; + } + case "studios": { + getInternalMultiSelect = () => { return InternalStudioMultiSelect; }; + getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; } + translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); }; + break; + } + case "tags": { + getInternalMultiSelect = () => { return InternalTagMultiSelect; }; + getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; } + translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); }; + createNewObject = createNewTag; + break; + } + default: { + throw "Unhandled case in FilterMultiSelect"; + } + } + + return { + getInternalMultiSelect: getInternalMultiSelect, + getData: getData, + translateData: translateData, + createNewObject: createNewObject + }; } const renderItem: ItemRenderer = (item, itemProps) => { @@ -165,7 +182,7 @@ export const FilterMultiSelect: React.FunctionComponent = (props: IProps onItemSelect={onItemSelect} resetOnSelect={true} popoverProps={{position: "bottom"}} - createNewItemFromQuery={createNewFunc} + createNewItemFromQuery={MultiSelectImpl.createNewObject} createNewItemRenderer={createNewRenderer} {...props} /> diff --git a/ui/v2/src/core/StashService.ts b/ui/v2/src/core/StashService.ts index d7c2796e0..027ae6306 100644 --- a/ui/v2/src/core/StashService.ts +++ b/ui/v2/src/core/StashService.ts @@ -179,6 +179,10 @@ export class StashService { return GQL.useSceneUpdate({ variables: input }); } + public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) { + return GQL.useBulkSceneUpdate({ variables: input, refetchQueries: ["FindScenes"] }); + } + public static useSceneDestroy(input: GQL.SceneDestroyInput) { return GQL.useSceneDestroy({ variables: input }); } diff --git a/ui/v2/src/hooks/ListHook.tsx b/ui/v2/src/hooks/ListHook.tsx index 097377e86..2d8734022 100644 --- a/ui/v2/src/hooks/ListHook.tsx +++ b/ui/v2/src/hooks/ListHook.tsx @@ -15,17 +15,22 @@ export interface IListHookData { filter: ListFilterModel; template: JSX.Element; options: IListHookOptions; + onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void; } export interface IListHookOptions { filterMode: FilterMode; props: IBaseProps; - renderContent: (result: QueryHookResult, filter: ListFilterModel) => JSX.Element | undefined; + renderContent: (result: QueryHookResult, filter: ListFilterModel, selectedIds: Set) => JSX.Element | undefined; + renderSelectedOptions?: (result: QueryHookResult, selectedIds: Set) => JSX.Element | undefined; } export class ListHook { public static useList(options: IListHookOptions): IListHookData { const [filter, setFilter] = useState(new ListFilterModel(options.filterMode)); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastClickedId, setLastClickedId] = useState(undefined); + const [totalCount, setTotalCount] = useState(0); // Update the filter when the query parameters change useEffect(() => { @@ -39,42 +44,61 @@ export class ListHook { }, [options.props.location.search]); let result: QueryHookResult; - let totalCount: number; + + let getData: (filter : ListFilterModel) => QueryHookResult; + let getItems: () => any[]; + let getCount: () => number; switch (options.filterMode) { case FilterMode.Scenes: { - result = StashService.useFindScenes(filter); - totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); } + getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; } + getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; } break; } case FilterMode.SceneMarkers: { - result = StashService.useFindSceneMarkers(filter); - totalCount = !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); } + getItems = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.scene_markers : []; } + getCount = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0; } break; } case FilterMode.Galleries: { - result = StashService.useFindGalleries(filter); - totalCount = !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); } + getItems = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.galleries : []; } + getCount = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0; } break; } case FilterMode.Studios: { - result = StashService.useFindStudios(filter); - totalCount = !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindStudios(filter); } + getItems = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.studios : []; } + getCount = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0; } break; } case FilterMode.Performers: { - result = StashService.useFindPerformers(filter); - totalCount = !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); } + getItems = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.performers : []; } + getCount = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0; } break; } default: { console.error("REMOVE DEFAULT IN LIST HOOK"); - result = StashService.useFindScenes(filter); - totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; + getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); } + getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; } + getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; } break; } } + result = getData(filter); + + useEffect(() => { + setTotalCount(getCount()); + + // select none when data changes + onSelectNone(); + setLastClickedId(undefined); + }, [result.data]) + // Update the query parameters when the data changes useEffect(() => { const location = Object.assign({}, options.props.history.location); @@ -159,6 +183,77 @@ export class ListHook { setFilter(newFilter); } + function onSelectChange(id: string, selected : boolean, shiftKey: boolean) { + if (shiftKey) { + multiSelect(id, selected); + } else { + singleSelect(id, selected); + } + } + + function singleSelect(id: string, selected: boolean) { + setLastClickedId(id); + + const newSelectedIds = _.clone(selectedIds); + if (selected) { + newSelectedIds.add(id); + } else { + newSelectedIds.delete(id); + } + + setSelectedIds(newSelectedIds); + } + + function multiSelect(id: string, selected : boolean) { + let startIndex = 0; + let thisIndex = -1; + + if (!!lastClickedId) { + startIndex = getItems().findIndex((item) => { + return item.id === lastClickedId; + }); + } + + thisIndex = getItems().findIndex((item) => { + return item.id === id; + }); + + selectRange(startIndex, thisIndex); + } + + function selectRange(startIndex : number, endIndex : number) { + if (startIndex > endIndex) { + let tmp = startIndex; + startIndex = endIndex; + endIndex = tmp; + } + + const subset = getItems().slice(startIndex, endIndex + 1); + const newSelectedIds : Set = new Set(); + + subset.forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + } + + function onSelectAll() { + const newSelectedIds : Set = new Set(); + getItems().forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + function onSelectNone() { + const newSelectedIds : Set = new Set(); + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + const template = (
+ {options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined} {result.loading ? : undefined} {result.error ?

{result.error.message}

: undefined} - {options.renderContent(result, filter)} + {options.renderContent(result, filter, selectedIds)} ); - return { filter, template, options }; + return { filter, template, options, onSelectChange }; } } diff --git a/ui/v2/src/index.scss b/ui/v2/src/index.scss index 2b6c393f9..82ff44ef7 100755 --- a/ui/v2/src/index.scss +++ b/ui/v2/src/index.scss @@ -87,6 +87,14 @@ code { position: relative; } +.grid-item label.card-select { + position: absolute; + padding-left: 15px; + margin-top: -12px; + z-index: 10; + opacity: 0.5; +} + video.preview { // height: 225px; // slows down the page width: 100%; @@ -95,7 +103,7 @@ video.preview { object-fit: cover; } -.filter-item { +.filter-item, .operation-item { margin: 0 10px; } @@ -116,7 +124,7 @@ video.preview { } } -.filter-container { +.filter-container, .operation-container { display: flex; justify-content: center; margin: 10px auto;