Containing Group/Sub-Group relationships (#5105)

* Add UI support for setting containing groups
* Show containing groups in group details panel
* Move tag hierarchical filter code into separate type
* Add depth to scene_count and add sub_group_count
* Add sub-groups tab to groups page
* Add containing groups to edit groups dialog
* Show containing group description in sub-group view
* Show group scene number in group scenes view
* Add ability to drag move grid cards
* Add sub group order option
* Add reorder sub-groups interface
* Separate page size selector component
* Add interfaces to add and remove sub-groups to a group
* Separate MultiSet components
* Allow setting description while setting containing groups
This commit is contained in:
WithoutPants 2024-08-30 11:43:44 +10:00 committed by GitHub
parent 96fdd94a01
commit bcf0fda7ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 5388 additions and 935 deletions

View file

@ -359,6 +359,12 @@ type Mutation {
groupsDestroy(ids: [ID!]!): Boolean! groupsDestroy(ids: [ID!]!): Boolean!
bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!] bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!]
addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean!
removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean!
"Reorder sub groups within a group. Returns true if successful."
reorderSubGroups(input: ReorderSubGroupsInput!): Boolean!
tagCreate(input: TagCreateInput!): Tag tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag
tagDestroy(input: TagDestroyInput!): Boolean! tagDestroy(input: TagDestroyInput!): Boolean!

View file

@ -261,7 +261,7 @@ input SceneFilterType {
"Filter to only include scenes with this movie" "Filter to only include scenes with this movie"
movies: MultiCriterionInput @deprecated(reason: "use groups instead") movies: MultiCriterionInput @deprecated(reason: "use groups instead")
"Filter to only include scenes with this group" "Filter to only include scenes with this group"
groups: MultiCriterionInput groups: HierarchicalMultiCriterionInput
"Filter to only include scenes with this gallery" "Filter to only include scenes with this gallery"
galleries: MultiCriterionInput galleries: MultiCriterionInput
"Filter to only include scenes with these tags" "Filter to only include scenes with these tags"
@ -390,6 +390,15 @@ input GroupFilterType {
"Filter by last update time" "Filter by last update time"
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
"Filter by containing groups"
containing_groups: HierarchicalMultiCriterionInput
"Filter by sub groups"
sub_groups: HierarchicalMultiCriterionInput
"Filter by number of containing groups the group has"
containing_group_count: IntCriterionInput
"Filter by number of sub-groups the group has"
sub_group_count: IntCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria" "Filter by related studios that meet this criteria"

View file

@ -1,3 +1,9 @@
"GroupDescription represents a relationship to a group with a description of the relationship"
type GroupDescription {
group: Group!
description: String
}
type Group { type Group {
id: ID! id: ID!
name: String! name: String!
@ -15,12 +21,21 @@ type Group {
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
containing_groups: [GroupDescription!]!
sub_groups: [GroupDescription!]!
front_image_path: String # Resolver front_image_path: String # Resolver
back_image_path: String # Resolver back_image_path: String # Resolver
scene_count: Int! # Resolver scene_count(depth: Int): Int! # Resolver
sub_group_count(depth: Int): Int! # Resolver
scenes: [Scene!]! scenes: [Scene!]!
} }
input GroupDescriptionInput {
group_id: ID!
description: String
}
input GroupCreateInput { input GroupCreateInput {
name: String! name: String!
aliases: String aliases: String
@ -34,6 +49,10 @@ input GroupCreateInput {
synopsis: String synopsis: String
urls: [String!] urls: [String!]
tag_ids: [ID!] tag_ids: [ID!]
containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
@ -53,12 +72,21 @@ input GroupUpdateInput {
synopsis: String synopsis: String
urls: [String!] urls: [String!]
tag_ids: [ID!] tag_ids: [ID!]
containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
back_image: String back_image: String
} }
input BulkUpdateGroupDescriptionsInput {
groups: [GroupDescriptionInput!]!
mode: BulkUpdateIdMode!
}
input BulkGroupUpdateInput { input BulkGroupUpdateInput {
clientMutationId: String clientMutationId: String
ids: [ID!] ids: [ID!]
@ -68,13 +96,42 @@ input BulkGroupUpdateInput {
director: String director: String
urls: BulkUpdateStrings urls: BulkUpdateStrings
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
containing_groups: BulkUpdateGroupDescriptionsInput
sub_groups: BulkUpdateGroupDescriptionsInput
} }
input GroupDestroyInput { input GroupDestroyInput {
id: ID! id: ID!
} }
input ReorderSubGroupsInput {
"ID of the group to reorder sub groups for"
group_id: ID!
"""
IDs of the sub groups to reorder. These must be a subset of the current sub groups.
Sub groups will be inserted in this order at the insert_index
"""
sub_group_ids: [ID!]!
"The sub-group ID at which to insert the sub groups"
insert_at_id: ID!
"If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before"
insert_after: Boolean
}
type FindGroupsResultType { type FindGroupsResultType {
count: Int! count: Int!
groups: [Group!]! groups: [Group!]!
} }
input GroupSubGroupAddInput {
containing_group_id: ID!
sub_groups: [GroupDescriptionInput!]!
"The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end"
insert_index: Int
}
input GroupSubGroupRemoveInput {
containing_group_id: ID!
sub_group_ids: [ID!]!
}

View file

@ -18,7 +18,7 @@ type Movie {
front_image_path: String # Resolver front_image_path: String # Resolver
back_image_path: String # Resolver back_image_path: String # Resolver
scene_count: Int! # Resolver scene_count(depth: Int): Int! # Resolver
scenes: [Scene!]! scenes: [Scene!]!
} }

View file

@ -56,7 +56,7 @@ type Performer {
weight: Int weight: Int
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
groups: [Group!]! @deprecated(reason: "use groups instead") groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead") movies: [Movie!]! @deprecated(reason: "use groups instead")
} }

View file

@ -434,3 +434,64 @@ func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field stri
Mode: value.Mode, Mode: value.Mode,
}, nil }, nil
} }
func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) {
ret := make([]models.GroupIDDescription, len(input))
for i, v := range input {
gID, err := strconv.Atoi(v.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group ID: %s", v.GroupID)
}
ret[i] = models.GroupIDDescription{
GroupID: gID,
}
if v.Description != nil {
ret[i].Description = *v.Description
}
}
return ret, nil
}
func (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) {
groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return models.RelatedGroupDescriptions{}, err
}
return models.NewRelatedGroupDescriptions(groupsScenes), nil
}
func (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) {
return nil, nil
}
groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return nil, err
}
return &models.UpdateGroupDescriptions{
Groups: groupsScenes,
Mode: models.RelationshipUpdateModeSet,
}, nil
}
func (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) || value == nil {
return nil, nil
}
groups, err := groupsDescriptionsFromGroupInput(value.Groups)
if err != nil {
return nil, err
}
return &models.UpdateGroupDescriptions{
Groups: groups,
Mode: value.Mode,
}, nil
}

View file

@ -37,6 +37,7 @@ type Resolver struct {
sceneService manager.SceneService sceneService manager.SceneService
imageService manager.ImageService imageService manager.ImageService
galleryService manager.GalleryService galleryService manager.GalleryService
groupService manager.GroupService
hookExecutor hookExecutor hookExecutor hookExecutor
} }

View file

@ -5,7 +5,9 @@ import (
"github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
) )
func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) { func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) {
@ -71,6 +73,68 @@ func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*mode
return ret, firstError(errs) return ret, firstError(errs)
} }
func (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) {
// rgd must be loaded
gds := rgd.List()
ids := make([]int, len(gds))
for i, gd := range gds {
ids[i] = gd.GroupID
}
groups, errs := loaders.From(ctx).GroupByID.LoadAll(ids)
err = firstError(errs)
if err != nil {
return
}
ret = make([]*GroupDescription, len(groups))
for i, group := range groups {
ret[i] = &GroupDescription{Group: group}
d := gds[i].Description
if d != "" {
ret[i].Description = &d
}
}
return ret, firstError(errs)
}
func (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.ContainingGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadContainingGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
return r.relatedGroups(ctx, obj.ContainingGroups)
}
func (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.SubGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadSubGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
return r.relatedGroups(ctx, obj.SubGroups)
}
func (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) { func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) {
var hasImage bool var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
@ -106,9 +170,9 @@ func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*
return &imagePath, nil return &imagePath, nil
} }
func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group) (ret int, err error) { func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Scene.CountByGroupID(ctx, obj.ID) ret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth)
return err return err
}); err != nil { }); err != nil {
return 0, err return 0, err

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice"
@ -43,6 +44,16 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
newGroup.ContainingGroups, err = translator.groupIDDescriptions(input.ContainingGroups)
if err != nil {
return nil, fmt.Errorf("converting containing group ids: %w", err)
}
newGroup.SubGroups, err = translator.groupIDDescriptions(input.SubGroups)
if err != nil {
return nil, fmt.Errorf("converting containing group ids: %w", err)
}
if input.Urls != nil { if input.Urls != nil {
newGroup.URLs = models.NewRelatedStrings(input.Urls) newGroup.URLs = models.NewRelatedStrings(input.Urls)
} }
@ -82,26 +93,10 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp
// Start the transaction and save the group // Start the transaction and save the group
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil {
err = qb.Create(ctx, newGroup)
if err != nil {
return err return err
} }
// update image table
if len(frontimageData) > 0 {
if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil {
return err
}
}
if len(backimageData) > 0 {
if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil {
return err
}
}
return nil return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
@ -141,6 +136,18 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou
return return
} }
updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptions(input.ContainingGroups, "containing_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.SubGroups, err = translator.updateGroupIDDescriptions(input.SubGroups, "sub_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") updatedGroup.URLs = translator.updateStrings(input.Urls, "urls")
return updatedGroup, nil return updatedGroup, nil
@ -179,37 +186,31 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
} }
} }
// Start the transaction and save the group
var group *models.Group
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group frontImage := group.ImageInput{
group, err = qb.UpdatePartial(ctx, groupID, updatedGroup) Image: frontimageData,
Set: frontImageIncluded,
}
backImage := group.ImageInput{
Image: backimageData,
Set: backImageIncluded,
}
_, err = r.groupService.UpdatePartial(ctx, groupID, updatedGroup, frontImage, backImage)
if err != nil { if err != nil {
return err return err
} }
// update image table
if frontImageIncluded {
if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil {
return err
}
}
if backImageIncluded {
if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil {
return err
}
}
return nil return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
// for backwards compatibility - run both movie and group hooks // for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.GroupUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.MovieUpdatePost, input, translator.getFields())
return r.getGroup(ctx, group.ID) return r.getGroup(ctx, groupID)
} }
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
@ -230,6 +231,18 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input
return return
} }
updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptionsBulk(input.ContainingGroups, "containing_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.SubGroups, err = translator.updateGroupIDDescriptionsBulk(input.SubGroups, "sub_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)
return updatedGroup, nil return updatedGroup, nil
@ -254,10 +267,8 @@ func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupU
ret := []*models.Group{} ret := []*models.Group{}
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
for _, groupID := range groupIDs { for _, groupID := range groupIDs {
group, err := qb.UpdatePartial(ctx, groupID, updatedGroup) group, err := r.groupService.UpdatePartial(ctx, groupID, updatedGroup, group.ImageInput{}, group.ImageInput{})
if err != nil { if err != nil {
return err return err
} }
@ -333,3 +344,70 @@ func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string)
return true, nil return true, nil
} }
func (r *mutationResolver) AddGroupSubGroups(ctx context.Context, input GroupSubGroupAddInput) (bool, error) {
groupID, err := strconv.Atoi(input.ContainingGroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroups, err := groupsDescriptionsFromGroupInput(input.SubGroups)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.AddSubGroups(ctx, groupID, subGroups, input.InsertIndex)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) RemoveGroupSubGroups(ctx context.Context, input GroupSubGroupRemoveInput) (bool, error) {
groupID, err := strconv.Atoi(input.ContainingGroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.RemoveSubGroups(ctx, groupID, subGroupIDs)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ReorderSubGroups(ctx context.Context, input ReorderSubGroupsInput) (bool, error) {
groupID, err := strconv.Atoi(input.GroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
insertPointID, err := strconv.Atoi(input.InsertAtID)
if err != nil {
return false, fmt.Errorf("converting insert at id: %w", err)
}
insertAfter := utils.IsTrue(input.InsertAfter)
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter)
}); err != nil {
return false, err
}
return true, nil
}

View file

@ -158,11 +158,13 @@ func Initialize() (*Server, error) {
sceneService := mgr.SceneService sceneService := mgr.SceneService
imageService := mgr.ImageService imageService := mgr.ImageService
galleryService := mgr.GalleryService galleryService := mgr.GalleryService
groupService := mgr.GroupService
resolver := &Resolver{ resolver := &Resolver{
repository: repo, repository: repo,
sceneService: sceneService, sceneService: sceneService,
imageService: imageService, imageService: imageService,
galleryService: galleryService, galleryService: galleryService,
groupService: groupService,
hookExecutor: pluginCache, hookExecutor: pluginCache,
} }

View file

@ -682,7 +682,7 @@ func (me *contentDirectoryService) getGroups() []interface{} {
func (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} { func (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} {
sceneFilter := &models.SceneFilterType{ sceneFilter := &models.SceneFilterType{
Groups: &models.MultiCriterionInput{ Groups: &models.HierarchicalMultiCriterionInput{
Modifier: models.CriterionModifierIncludes, Modifier: models.CriterionModifierIncludes,
Value: []string{paths[0]}, Value: []string{paths[0]},
}, },

View file

@ -17,6 +17,7 @@ import (
"github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
@ -67,6 +68,10 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
Folder: db.Folder, Folder: db.Folder,
} }
groupService := &group.Service{
Repository: db.Group,
}
sceneServer := &SceneServer{ sceneServer := &SceneServer{
TxnManager: repo.TxnManager, TxnManager: repo.TxnManager,
SceneCoverGetter: repo.Scene, SceneCoverGetter: repo.Scene,
@ -99,6 +104,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
SceneService: sceneService, SceneService: sceneService,
ImageService: imageService, ImageService: imageService,
GalleryService: galleryService, GalleryService: galleryService,
GroupService: groupService,
scanSubs: &subscriptionManager{}, scanSubs: &subscriptionManager{},
} }

View file

@ -66,6 +66,7 @@ type Manager struct {
SceneService SceneService SceneService SceneService
ImageService ImageService ImageService ImageService
GalleryService GalleryService GalleryService GalleryService
GroupService GroupService
scanSubs *subscriptionManager scanSubs *subscriptionManager
} }

View file

@ -3,6 +3,7 @@ package manager
import ( import (
"context" "context"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene"
@ -33,3 +34,12 @@ type GalleryService interface {
Updated(ctx context.Context, galleryID int) error Updated(ctx context.Context, galleryID int) error
} }
type GroupService interface {
Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error
UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error)
AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error
RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error
ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error
}

View file

@ -1134,6 +1134,10 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha
logger.Errorf("[groups] <%s> error getting group urls: %v", m.Name, err) logger.Errorf("[groups] <%s> error getting group urls: %v", m.Name, err)
continue continue
} }
if err := m.LoadSubGroupIDs(ctx, r.Group); err != nil {
logger.Errorf("[groups] <%s> error getting group sub-groups: %v", m.Name, err)
continue
}
newGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m) newGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m)
@ -1150,6 +1154,25 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha
newGroupJSON.Tags = tag.GetNames(tags) newGroupJSON.Tags = tag.GetNames(tags)
subGroups := m.SubGroups.List()
if err := func() error {
for _, sg := range subGroups {
subGroup, err := groupReader.Find(ctx, sg.GroupID)
if err != nil {
return fmt.Errorf("error getting sub group: %v", err)
}
newGroupJSON.SubGroups = append(newGroupJSON.SubGroups, jsonschema.SubGroupDescription{
// TODO - this won't be unique
Group: subGroup.Name,
Description: sg.Description,
})
}
return nil
}(); err != nil {
logger.Errorf("[groups] <%s> %v", m.Name, err)
}
if t.includeDependencies { if t.includeDependencies {
if m.StudioID != nil { if m.StudioID != nil {
t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID)

View file

@ -327,6 +327,7 @@ func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.St
func (t *ImportTask) ImportGroups(ctx context.Context) { func (t *ImportTask) ImportGroups(ctx context.Context) {
logger.Info("[groups] importing") logger.Info("[groups] importing")
pendingSubs := make(map[string][]*jsonschema.Group)
path := t.json.json.Groups path := t.json.json.Groups
files, err := os.ReadDir(path) files, err := os.ReadDir(path)
@ -351,24 +352,72 @@ func (t *ImportTask) ImportGroups(ctx context.Context) {
logger.Progressf("[groups] %d of %d", index, len(files)) logger.Progressf("[groups] %d of %d", index, len(files))
if err := r.WithTxn(ctx, func(ctx context.Context) error { if err := r.WithTxn(ctx, func(ctx context.Context) error {
groupImporter := &group.Importer{ return t.importGroup(ctx, groupJSON, pendingSubs, false)
ReaderWriter: r.Group, }); err != nil {
StudioWriter: r.Studio, var subError group.SubGroupNotExistError
TagWriter: r.Tag, if errors.As(err, &subError) {
Input: *groupJSON, missingSub := subError.MissingSubGroup()
MissingRefBehaviour: t.MissingRefBehaviour, pendingSubs[missingSub] = append(pendingSubs[missingSub], groupJSON)
continue
} }
return performImport(ctx, groupImporter, t.DuplicateBehaviour) logger.Errorf("[groups] <%s> failed to import: %v", fi.Name(), err)
}); err != nil {
logger.Errorf("[groups] <%s> import failed: %v", fi.Name(), err)
continue continue
} }
} }
for _, s := range pendingSubs {
for _, orphanGroupJSON := range s {
if err := r.WithTxn(ctx, func(ctx context.Context) error {
return t.importGroup(ctx, orphanGroupJSON, nil, true)
}); err != nil {
logger.Errorf("[groups] <%s> failed to create: %v", orphanGroupJSON.Name, err)
continue
}
}
}
logger.Info("[groups] import complete") logger.Info("[groups] import complete")
} }
func (t *ImportTask) importGroup(ctx context.Context, groupJSON *jsonschema.Group, pendingSub map[string][]*jsonschema.Group, fail bool) error {
r := t.repository
importer := &group.Importer{
ReaderWriter: r.Group,
StudioWriter: r.Studio,
TagWriter: r.Tag,
Input: *groupJSON,
MissingRefBehaviour: t.MissingRefBehaviour,
}
// first phase: return error if parent does not exist
if !fail {
importer.MissingRefBehaviour = models.ImportMissingRefEnumFail
}
if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {
return err
}
for _, containingGroupJSON := range pendingSub[groupJSON.Name] {
if err := t.importGroup(ctx, containingGroupJSON, pendingSub, fail); err != nil {
var subError group.SubGroupNotExistError
if errors.As(err, &subError) {
missingSub := subError.MissingSubGroup()
pendingSub[missingSub] = append(pendingSub[missingSub], containingGroupJSON)
continue
}
return fmt.Errorf("failed to create containing group <%s>: %v", containingGroupJSON.Name, err)
}
}
delete(pendingSub, groupJSON.Name)
return nil
}
func (t *ImportTask) ImportFiles(ctx context.Context) { func (t *ImportTask) ImportFiles(ctx context.Context) {
logger.Info("[files] importing") logger.Info("[files] importing")

41
pkg/group/create.go Normal file
View file

@ -0,0 +1,41 @@
package group
import (
"context"
"errors"
"github.com/stashapp/stash/pkg/models"
)
var (
ErrEmptyName = errors.New("name cannot be empty")
ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups")
)
func (s *Service) Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error {
r := s.Repository
if err := s.validateCreate(ctx, group); err != nil {
return err
}
err := r.Create(ctx, group)
if err != nil {
return err
}
// update image table
if len(frontimageData) > 0 {
if err := r.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil {
return err
}
}
if len(backimageData) > 0 {
if err := r.UpdateBackImage(ctx, group.ID, backimageData); err != nil {
return err
}
}
return nil
}

View file

@ -16,6 +16,18 @@ type ImporterReaderWriter interface {
FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error)
} }
type SubGroupNotExistError struct {
missingSubGroup string
}
func (e SubGroupNotExistError) Error() string {
return fmt.Sprintf("sub group <%s> does not exist", e.missingSubGroup)
}
func (e SubGroupNotExistError) MissingSubGroup() string {
return e.missingSubGroup
}
type Importer struct { type Importer struct {
ReaderWriter ImporterReaderWriter ReaderWriter ImporterReaderWriter
StudioWriter models.StudioFinderCreator StudioWriter models.StudioFinderCreator
@ -202,6 +214,22 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
} }
func (i *Importer) PostImport(ctx context.Context, id int) error { func (i *Importer) PostImport(ctx context.Context, id int) error {
subGroups, err := i.getSubGroups(ctx)
if err != nil {
return err
}
if len(subGroups) > 0 {
if _, err := i.ReaderWriter.UpdatePartial(ctx, id, models.GroupPartial{
SubGroups: &models.UpdateGroupDescriptions{
Groups: subGroups,
Mode: models.RelationshipUpdateModeSet,
},
}); err != nil {
return fmt.Errorf("error setting parents: %v", err)
}
}
if len(i.frontImageData) > 0 { if len(i.frontImageData) > 0 {
if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil {
return fmt.Errorf("error setting group front image: %v", err) return fmt.Errorf("error setting group front image: %v", err)
@ -256,3 +284,53 @@ func (i *Importer) Update(ctx context.Context, id int) error {
return nil return nil
} }
func (i *Importer) getSubGroups(ctx context.Context) ([]models.GroupIDDescription, error) {
var subGroups []models.GroupIDDescription
for _, subGroup := range i.Input.SubGroups {
group, err := i.ReaderWriter.FindByName(ctx, subGroup.Group, false)
if err != nil {
return nil, fmt.Errorf("error finding parent by name: %v", err)
}
if group == nil {
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
return nil, SubGroupNotExistError{missingSubGroup: subGroup.Group}
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {
continue
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {
parentID, err := i.createSubGroup(ctx, subGroup.Group)
if err != nil {
return nil, err
}
subGroups = append(subGroups, models.GroupIDDescription{
GroupID: parentID,
Description: subGroup.Description,
})
}
} else {
subGroups = append(subGroups, models.GroupIDDescription{
GroupID: group.ID,
Description: subGroup.Description,
})
}
}
return subGroups, nil
}
func (i *Importer) createSubGroup(ctx context.Context, name string) (int, error) {
newGroup := models.NewGroup()
newGroup.Name = name
err := i.ReaderWriter.Create(ctx, &newGroup)
if err != nil {
return 0, err
}
return newGroup.ID, nil
}

View file

@ -30,3 +30,15 @@ func CountByTagID(ctx context.Context, r models.GroupQueryer, id int, depth *int
return r.QueryCount(ctx, filter, nil) return r.QueryCount(ctx, filter, nil)
} }
func CountByContainingGroupID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) {
filter := &models.GroupFilterType{
ContainingGroups: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}

33
pkg/group/reorder.go Normal file
View file

@ -0,0 +1,33 @@
package group
import (
"context"
"errors"
"github.com/stashapp/stash/pkg/models"
)
var ErrInvalidInsertIndex = errors.New("invalid insert index")
func (s *Service) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error {
// get the group
existing, err := s.Repository.Find(ctx, groupID)
if err != nil {
return err
}
// ensure it exists
if existing == nil {
return models.ErrNotFound
}
// TODO - ensure the subgroups exist in the group
// ensure the insert index is valid
if insertPointID < 0 {
return ErrInvalidInsertIndex
}
// reorder the subgroups
return s.Repository.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter)
}

46
pkg/group/service.go Normal file
View file

@ -0,0 +1,46 @@
package group
import (
"context"
"github.com/stashapp/stash/pkg/models"
)
type CreatorUpdater interface {
models.GroupGetter
models.GroupCreator
models.GroupUpdater
models.ContainingGroupLoader
models.SubGroupLoader
AnscestorFinder
SubGroupIDFinder
SubGroupAdder
SubGroupRemover
SubGroupReorderer
}
type AnscestorFinder interface {
FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error)
}
type SubGroupIDFinder interface {
FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error)
}
type SubGroupAdder interface {
AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error
}
type SubGroupRemover interface {
RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error
}
type SubGroupReorderer interface {
ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertID int, insertAfter bool) error
}
type Service struct {
Repository CreatorUpdater
}

112
pkg/group/update.go Normal file
View file

@ -0,0 +1,112 @@
package group
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
type SubGroupAlreadyInGroupError struct {
GroupIDs []int
}
func (e *SubGroupAlreadyInGroupError) Error() string {
return fmt.Sprintf("subgroups with IDs %v already in group", e.GroupIDs)
}
type ImageInput struct {
Image []byte
Set bool
}
func (s *Service) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage ImageInput, backImage ImageInput) (*models.Group, error) {
if err := s.validateUpdate(ctx, id, updatedGroup); err != nil {
return nil, err
}
r := s.Repository
group, err := r.UpdatePartial(ctx, id, updatedGroup)
if err != nil {
return nil, err
}
// update image table
if frontImage.Set {
if err := r.UpdateFrontImage(ctx, id, frontImage.Image); err != nil {
return nil, err
}
}
if backImage.Set {
if err := r.UpdateBackImage(ctx, id, backImage.Image); err != nil {
return nil, err
}
}
return group, nil
}
func (s *Service) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error {
// get the group
existing, err := s.Repository.Find(ctx, groupID)
if err != nil {
return err
}
// ensure it exists
if existing == nil {
return models.ErrNotFound
}
// ensure the subgroups aren't already sub-groups of the group
subGroupIDs := sliceutil.Map(subGroups, func(sg models.GroupIDDescription) int {
return sg.GroupID
})
existingSubGroupIDs, err := s.Repository.FindSubGroupIDs(ctx, groupID, subGroupIDs)
if err != nil {
return err
}
if len(existingSubGroupIDs) > 0 {
return &SubGroupAlreadyInGroupError{
GroupIDs: existingSubGroupIDs,
}
}
// validate the hierarchy
d := &models.UpdateGroupDescriptions{
Groups: subGroups,
Mode: models.RelationshipUpdateModeAdd,
}
if err := s.validateUpdateGroupHierarchy(ctx, existing, nil, d); err != nil {
return err
}
// validate insert index
if insertIndex != nil && *insertIndex < 0 {
return ErrInvalidInsertIndex
}
// add the subgroups
return s.Repository.AddSubGroups(ctx, groupID, subGroups, insertIndex)
}
func (s *Service) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error {
// get the group
existing, err := s.Repository.Find(ctx, groupID)
if err != nil {
return err
}
// ensure it exists
if existing == nil {
return models.ErrNotFound
}
// add the subgroups
return s.Repository.RemoveSubGroups(ctx, groupID, subGroupIDs)
}

117
pkg/group/validate.go Normal file
View file

@ -0,0 +1,117 @@
package group
import (
"context"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
func (s *Service) validateCreate(ctx context.Context, group *models.Group) error {
if err := validateName(group.Name); err != nil {
return err
}
containingIDs := group.ContainingGroups.IDs()
subIDs := group.SubGroups.IDs()
if err := s.validateGroupHierarchy(ctx, containingIDs, subIDs); err != nil {
return err
}
return nil
}
func (s *Service) validateUpdate(ctx context.Context, id int, partial models.GroupPartial) error {
// get the existing group - ensure it exists
existing, err := s.Repository.Find(ctx, id)
if err != nil {
return err
}
if existing == nil {
return models.ErrNotFound
}
if partial.Name.Set {
if err := validateName(partial.Name.Value); err != nil {
return err
}
}
if err := s.validateUpdateGroupHierarchy(ctx, existing, partial.ContainingGroups, partial.SubGroups); err != nil {
return err
}
return nil
}
func validateName(n string) error {
// ensure name is not empty
if strings.TrimSpace(n) == "" {
return ErrEmptyName
}
return nil
}
func (s *Service) validateGroupHierarchy(ctx context.Context, containingIDs []int, subIDs []int) error {
// only need to validate if both are non-empty
if len(containingIDs) == 0 || len(subIDs) == 0 {
return nil
}
// ensure none of the containing groups are in the sub groups
found, err := s.Repository.FindInAncestors(ctx, containingIDs, subIDs)
if err != nil {
return err
}
if len(found) > 0 {
return ErrHierarchyLoop
}
return nil
}
func (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *models.Group, containingGroups *models.UpdateGroupDescriptions, subGroups *models.UpdateGroupDescriptions) error {
// no need to validate if there are no changes
if containingGroups == nil && subGroups == nil {
return nil
}
if err := existing.LoadContainingGroupIDs(ctx, s.Repository); err != nil {
return err
}
existingContainingGroups := existing.ContainingGroups.List()
if err := existing.LoadSubGroupIDs(ctx, s.Repository); err != nil {
return err
}
existingSubGroups := existing.SubGroups.List()
effectiveContainingGroups := existingContainingGroups
if containingGroups != nil {
effectiveContainingGroups = containingGroups.Apply(existingContainingGroups)
}
effectiveSubGroups := existingSubGroups
if subGroups != nil {
effectiveSubGroups = subGroups.Apply(existingSubGroups)
}
containingIDs := idsFromGroupDescriptions(effectiveContainingGroups)
subIDs := idsFromGroupDescriptions(effectiveSubGroups)
// ensure we haven't set the group as a subgroup of itself
if sliceutil.Contains(containingIDs, existing.ID) || sliceutil.Contains(subIDs, existing.ID) {
return ErrHierarchyLoop
}
return s.validateGroupHierarchy(ctx, containingIDs, subIDs)
}
func idsFromGroupDescriptions(v []models.GroupIDDescription) []int {
return sliceutil.Map(v, func(g models.GroupIDDescription) int { return g.GroupID })
}

View file

@ -23,6 +23,14 @@ type GroupFilterType struct {
TagCount *IntCriterionInput `json:"tag_count"` TagCount *IntCriterionInput `json:"tag_count"`
// Filter by date // Filter by date
Date *DateCriterionInput `json:"date"` Date *DateCriterionInput `json:"date"`
// Filter by containing groups
ContainingGroups *HierarchicalMultiCriterionInput `json:"containing_groups"`
// Filter by sub groups
SubGroups *HierarchicalMultiCriterionInput `json:"sub_groups"`
// Filter by number of containing groups the group has
ContainingGroupCount *IntCriterionInput `json:"containing_group_count"`
// Filter by number of sub-groups the group has
SubGroupCount *IntCriterionInput `json:"sub_group_count"`
// Filter by related scenes that meet this criteria // Filter by related scenes that meet this criteria
ScenesFilter *SceneFilterType `json:"scenes_filter"` ScenesFilter *SceneFilterType `json:"scenes_filter"`
// Filter by related studios that meet this criteria // Filter by related studios that meet this criteria

View file

@ -11,21 +11,27 @@ import (
"github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/json"
) )
type SubGroupDescription struct {
Group string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
type Group struct { type Group struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Aliases string `json:"aliases,omitempty"` Aliases string `json:"aliases,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Director string `json:"director,omitempty"` Director string `json:"director,omitempty"`
Synopsis string `json:"synopsis,omitempty"` Synopsis string `json:"synopsis,omitempty"`
FrontImage string `json:"front_image,omitempty"` FrontImage string `json:"front_image,omitempty"`
BackImage string `json:"back_image,omitempty"` BackImage string `json:"back_image,omitempty"`
URLs []string `json:"urls,omitempty"` URLs []string `json:"urls,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` SubGroups []SubGroupDescription `json:"sub_groups,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
// deprecated - for import only // deprecated - for import only
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`

View file

@ -289,6 +289,29 @@ func (_m *GroupReaderWriter) GetBackImage(ctx context.Context, groupID int) ([]b
return r0, r1 return r0, r1
} }
// GetContainingGroupDescriptions provides a mock function with given fields: ctx, id
func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {
ret := _m.Called(ctx, id)
var r0 []models.GroupIDDescription
if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.GroupIDDescription)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetFrontImage provides a mock function with given fields: ctx, groupID // GetFrontImage provides a mock function with given fields: ctx, groupID
func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) {
ret := _m.Called(ctx, groupID) ret := _m.Called(ctx, groupID)
@ -312,6 +335,29 @@ func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]
return r0, r1 return r0, r1
} }
// GetSubGroupDescriptions provides a mock function with given fields: ctx, id
func (_m *GroupReaderWriter) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {
ret := _m.Called(ctx, id)
var r0 []models.GroupIDDescription
if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.GroupIDDescription)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetTagIDs provides a mock function with given fields: ctx, relatedID // GetTagIDs provides a mock function with given fields: ctx, relatedID
func (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { func (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {
ret := _m.Called(ctx, relatedID) ret := _m.Called(ctx, relatedID)

View file

@ -21,6 +21,9 @@ type Group struct {
URLs RelatedStrings `json:"urls"` URLs RelatedStrings `json:"urls"`
TagIDs RelatedIDs `json:"tag_ids"` TagIDs RelatedIDs `json:"tag_ids"`
ContainingGroups RelatedGroupDescriptions `json:"containing_groups"`
SubGroups RelatedGroupDescriptions `json:"sub_groups"`
} }
func NewGroup() Group { func NewGroup() Group {
@ -43,20 +46,34 @@ func (m *Group) LoadTagIDs(ctx context.Context, l TagIDLoader) error {
}) })
} }
func (m *Group) LoadContainingGroupIDs(ctx context.Context, l ContainingGroupLoader) error {
return m.ContainingGroups.load(func() ([]GroupIDDescription, error) {
return l.GetContainingGroupDescriptions(ctx, m.ID)
})
}
func (m *Group) LoadSubGroupIDs(ctx context.Context, l SubGroupLoader) error {
return m.SubGroups.load(func() ([]GroupIDDescription, error) {
return l.GetSubGroupDescriptions(ctx, m.ID)
})
}
type GroupPartial struct { type GroupPartial struct {
Name OptionalString Name OptionalString
Aliases OptionalString Aliases OptionalString
Duration OptionalInt Duration OptionalInt
Date OptionalDate Date OptionalDate
// Rating expressed in 1-100 scale // Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
StudioID OptionalInt StudioID OptionalInt
Director OptionalString Director OptionalString
Synopsis OptionalString Synopsis OptionalString
URLs *UpdateStrings URLs *UpdateStrings
TagIDs *UpdateIDs TagIDs *UpdateIDs
CreatedAt OptionalTime ContainingGroups *UpdateGroupDescriptions
UpdatedAt OptionalTime SubGroups *UpdateGroupDescriptions
CreatedAt OptionalTime
UpdatedAt OptionalTime
} }
func NewGroupPartial() GroupPartial { func NewGroupPartial() GroupPartial {

View file

@ -68,3 +68,8 @@ func GroupsScenesFromInput(input []SceneMovieInput) ([]GroupsScenes, error) {
return ret, nil return ret, nil
} }
type GroupIDDescription struct {
GroupID int `json:"group_id"`
Description string `json:"description"`
}

View file

@ -2,6 +2,8 @@ package models
import ( import (
"context" "context"
"github.com/stashapp/stash/pkg/sliceutil"
) )
type SceneIDLoader interface { type SceneIDLoader interface {
@ -37,6 +39,14 @@ type SceneGroupLoader interface {
GetGroups(ctx context.Context, id int) ([]GroupsScenes, error) GetGroups(ctx context.Context, id int) ([]GroupsScenes, error)
} }
type ContainingGroupLoader interface {
GetContainingGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error)
}
type SubGroupLoader interface {
GetSubGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error)
}
type StashIDLoader interface { type StashIDLoader interface {
GetStashIDs(ctx context.Context, relatedID int) ([]StashID, error) GetStashIDs(ctx context.Context, relatedID int) ([]StashID, error)
} }
@ -185,6 +195,82 @@ func (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error {
return nil return nil
} }
type RelatedGroupDescriptions struct {
list []GroupIDDescription
}
// NewRelatedGroups returns a loaded RelateGroups object with the provided groups.
// Loaded will return true when called on the returned object if the provided slice is not nil.
func NewRelatedGroupDescriptions(list []GroupIDDescription) RelatedGroupDescriptions {
return RelatedGroupDescriptions{
list: list,
}
}
// Loaded returns true if the relationship has been loaded.
func (r RelatedGroupDescriptions) Loaded() bool {
return r.list != nil
}
func (r RelatedGroupDescriptions) mustLoaded() {
if !r.Loaded() {
panic("list has not been loaded")
}
}
// List returns the related Groups. Panics if the relationship has not been loaded.
func (r RelatedGroupDescriptions) List() []GroupIDDescription {
r.mustLoaded()
return r.list
}
// List returns the related Groups. Panics if the relationship has not been loaded.
func (r RelatedGroupDescriptions) IDs() []int {
r.mustLoaded()
return sliceutil.Map(r.list, func(d GroupIDDescription) int { return d.GroupID })
}
// Add adds the provided ids to the list. Panics if the relationship has not been loaded.
func (r *RelatedGroupDescriptions) Add(groups ...GroupIDDescription) {
r.mustLoaded()
r.list = append(r.list, groups...)
}
// ForID returns the GroupsScenes object for the given group ID. Returns nil if not found.
func (r *RelatedGroupDescriptions) ForID(id int) *GroupIDDescription {
r.mustLoaded()
for _, v := range r.list {
if v.GroupID == id {
return &v
}
}
return nil
}
func (r *RelatedGroupDescriptions) load(fn func() ([]GroupIDDescription, error)) error {
if r.Loaded() {
return nil
}
ids, err := fn()
if err != nil {
return err
}
if ids == nil {
ids = []GroupIDDescription{}
}
r.list = ids
return nil
}
type RelatedStashIDs struct { type RelatedStashIDs struct {
list []StashID list []StashID
} }

View file

@ -66,6 +66,8 @@ type GroupReader interface {
GroupCounter GroupCounter
URLLoader URLLoader
TagIDLoader TagIDLoader
ContainingGroupLoader
SubGroupLoader
All(ctx context.Context) ([]*Group, error) All(ctx context.Context) ([]*Group, error)
GetFrontImage(ctx context.Context, groupID int) ([]byte, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error)

View file

@ -37,10 +37,7 @@ type SceneQueryer interface {
type SceneCounter interface { type SceneCounter interface {
Count(ctx context.Context) (int, error) Count(ctx context.Context) (int, error)
CountByPerformerID(ctx context.Context, performerID int) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error)
CountByGroupID(ctx context.Context, groupID int) (int, error)
CountByFileID(ctx context.Context, fileID FileID) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error)
CountByStudioID(ctx context.Context, studioID int) (int, error)
CountByTagID(ctx context.Context, tagID int) (int, error)
CountMissingChecksum(ctx context.Context) (int, error) CountMissingChecksum(ctx context.Context) (int, error)
CountMissingOSHash(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error)

View file

@ -56,7 +56,7 @@ type SceneFilterType struct {
// Filter to only include scenes with this studio // Filter to only include scenes with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"` Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include scenes with this group // Filter to only include scenes with this group
Groups *MultiCriterionInput `json:"groups"` Groups *HierarchicalMultiCriterionInput `json:"groups"`
// Filter to only include scenes with this movie // Filter to only include scenes with this movie
Movies *MultiCriterionInput `json:"movies"` Movies *MultiCriterionInput `json:"movies"`
// Filter to only include scenes with this gallery // Filter to only include scenes with this gallery

View file

@ -133,3 +133,68 @@ func applyUpdate[T comparable](values []T, mode RelationshipUpdateMode, existing
return nil return nil
} }
type UpdateGroupDescriptions struct {
Groups []GroupIDDescription `json:"groups"`
Mode RelationshipUpdateMode `json:"mode"`
}
// Apply applies the update to a list of existing ids, returning the result.
func (u *UpdateGroupDescriptions) Apply(existing []GroupIDDescription) []GroupIDDescription {
if u == nil {
return existing
}
switch u.Mode {
case RelationshipUpdateModeAdd:
return u.applyAdd(existing)
case RelationshipUpdateModeRemove:
return u.applyRemove(existing)
case RelationshipUpdateModeSet:
return u.Groups
}
return nil
}
func (u *UpdateGroupDescriptions) applyAdd(existing []GroupIDDescription) []GroupIDDescription {
// overwrite any existing values with the same id
ret := append([]GroupIDDescription{}, existing...)
for _, v := range u.Groups {
found := false
for i, vv := range ret {
if vv.GroupID == v.GroupID {
ret[i] = v
found = true
break
}
}
if !found {
ret = append(ret, v)
}
}
return ret
}
func (u *UpdateGroupDescriptions) applyRemove(existing []GroupIDDescription) []GroupIDDescription {
// remove any existing values with the same id
var ret []GroupIDDescription
for _, v := range existing {
found := false
for _, vv := range u.Groups {
if vv.GroupID == v.GroupID {
found = true
break
}
}
// if not found in the remove list, keep it
if !found {
ret = append(ret, v)
}
}
return ret
}

View file

@ -144,3 +144,15 @@ func CountByTagID(ctx context.Context, r models.SceneQueryer, id int, depth *int
return r.QueryCount(ctx, filter, nil) return r.QueryCount(ctx, filter, nil)
} }
func CountByGroupID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) {
filter := &models.SceneFilterType{
Groups: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}

View file

@ -146,7 +146,7 @@ func Filter[T any](vs []T, f func(T) bool) []T {
return ret return ret
} }
// Filter returns the result of applying f to each element of the vs slice. // Map returns the result of applying f to each element of the vs slice.
func Map[T any, V any](vs []T, f func(T) V) []V { func Map[T any, V any](vs []T, f func(T) V) []V {
ret := make([]V, len(vs)) ret := make([]V, len(vs))
for i, v := range vs { for i, v := range vs {

View file

@ -30,7 +30,7 @@ const (
dbConnTimeout = 30 dbConnTimeout = 30
) )
var appSchemaVersion uint = 66 var appSchemaVersion uint = 67
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

@ -0,0 +1,222 @@
package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
// hierarchicalRelationshipHandler provides handlers for parent, children, parent count, and child count criteria.
type hierarchicalRelationshipHandler struct {
primaryTable string
relationTable string
aliasPrefix string
parentIDCol string
childIDCol string
}
func (h hierarchicalRelationshipHandler) validateModifier(m models.CriterionModifier) error {
switch m {
case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull:
// valid
return nil
default:
return fmt.Errorf("invalid modifier %s", m)
}
}
func (h hierarchicalRelationshipHandler) handleNullNotNull(f *filterBuilder, m models.CriterionModifier, isParents bool) {
var notClause string
if m == models.CriterionModifierNotNull {
notClause = "NOT"
}
as := h.aliasPrefix + "_parents"
col := h.childIDCol
if !isParents {
as = h.aliasPrefix + "_children"
col = h.parentIDCol
}
// Based on:
// f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id")
// f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause))
f.addLeftJoin(h.relationTable, as, fmt.Sprintf("%s.id = %s.%s", h.primaryTable, as, col))
f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", as, col, notClause))
}
func (h hierarchicalRelationshipHandler) parentsAlias() string {
return h.aliasPrefix + "_parents"
}
func (h hierarchicalRelationshipHandler) childrenAlias() string {
return h.aliasPrefix + "_children"
}
func (h hierarchicalRelationshipHandler) valueQuery(value []string, depth int, alias string, isParents bool) string {
var depthCondition string
if depth != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depth)
}
queryTempl := `{alias} AS (
SELECT {root_id_col} AS root_id, {item_id_col} AS item_id, 0 AS depth FROM {relation_table} WHERE {root_id_col} IN` + getInBinding(len(value)) + `
UNION
SELECT root_id, {item_id_col}, depth + 1 FROM {relation_table} INNER JOIN {alias} ON item_id = {root_id_col} ` + depthCondition + `
)`
var queryMap utils.StrFormatMap
if isParents {
queryMap = utils.StrFormatMap{
"root_id_col": h.parentIDCol,
"item_id_col": h.childIDCol,
}
} else {
queryMap = utils.StrFormatMap{
"root_id_col": h.childIDCol,
"item_id_col": h.parentIDCol,
}
}
queryMap["alias"] = alias
queryMap["relation_table"] = h.relationTable
return utils.StrFormat(queryTempl, queryMap)
}
func (h hierarchicalRelationshipHandler) handleValues(f *filterBuilder, c models.HierarchicalMultiCriterionInput, isParents bool, aliasSuffix string) {
if len(c.Value) == 0 {
return
}
var args []interface{}
for _, val := range c.Value {
args = append(args, val)
}
depthVal := 0
if c.Depth != nil {
depthVal = *c.Depth
}
tableAlias := h.parentsAlias()
if !isParents {
tableAlias = h.childrenAlias()
}
tableAlias += aliasSuffix
query := h.valueQuery(c.Value, depthVal, tableAlias, isParents)
f.addRecursiveWith(query, args...)
f.addLeftJoin(tableAlias, "", fmt.Sprintf("%s.item_id = %s.id", tableAlias, h.primaryTable))
addHierarchicalConditionClauses(f, c, tableAlias, "root_id")
}
func (h hierarchicalRelationshipHandler) handleValuesSimple(f *filterBuilder, value string, isParents bool) {
joinCol := h.childIDCol
valueCol := h.parentIDCol
if !isParents {
joinCol = h.parentIDCol
valueCol = h.childIDCol
}
tableAlias := h.parentsAlias()
if !isParents {
tableAlias = h.childrenAlias()
}
f.addInnerJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, joinCol, h.primaryTable))
f.addWhere(fmt.Sprintf("%s.%s = ?", tableAlias, valueCol), value)
}
func (h hierarchicalRelationshipHandler) hierarchicalCriterionHandler(criterion *models.HierarchicalMultiCriterionInput, isParents bool) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if criterion != nil {
c := criterion.CombineExcludes()
// validate the modifier
if err := h.validateModifier(c.Modifier); err != nil {
f.setError(err)
return
}
if c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotNull {
h.handleNullNotNull(f, c.Modifier, isParents)
return
}
if len(c.Value) == 0 && len(c.Excludes) == 0 {
return
}
depth := 0
if c.Depth != nil {
depth = *c.Depth
}
// if we have a single include, no excludes, and no depth, we can use a simple join and where clause
if (c.Modifier == models.CriterionModifierIncludes || c.Modifier == models.CriterionModifierIncludesAll) && len(c.Value) == 1 && len(c.Excludes) == 0 && depth == 0 {
h.handleValuesSimple(f, c.Value[0], isParents)
return
}
aliasSuffix := ""
h.handleValues(f, c, isParents, aliasSuffix)
if len(c.Excludes) > 0 {
exCriterion := models.HierarchicalMultiCriterionInput{
Value: c.Excludes,
Depth: c.Depth,
Modifier: models.CriterionModifierExcludes,
}
aliasSuffix := "2"
h.handleValues(f, exCriterion, isParents, aliasSuffix)
}
}
}
}
func (h hierarchicalRelationshipHandler) ParentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
const isParents = true
return h.hierarchicalCriterionHandler(criterion, isParents)
}
func (h hierarchicalRelationshipHandler) ChildrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
const isParents = false
return h.hierarchicalCriterionHandler(criterion, isParents)
}
func (h hierarchicalRelationshipHandler) countCriterionHandler(c *models.IntCriterionInput, isParents bool) criterionHandlerFunc {
tableAlias := h.parentsAlias()
col := h.childIDCol
otherCol := h.parentIDCol
if !isParents {
tableAlias = h.childrenAlias()
col = h.parentIDCol
otherCol = h.childIDCol
}
tableAlias += "_count"
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
f.addLeftJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, col, h.primaryTable))
clause, args := getIntCriterionWhereClause(fmt.Sprintf("count(distinct %s.%s)", tableAlias, otherCol), *c)
f.addHaving(clause, args...)
}
}
}
func (h hierarchicalRelationshipHandler) ParentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc {
const isParents = true
return h.countCriterionHandler(parentCount, isParents)
}
func (h hierarchicalRelationshipHandler) ChildCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {
const isParents = false
return h.countCriterionHandler(childCount, isParents)
}

View file

@ -27,6 +27,8 @@ const (
groupURLsTable = "group_urls" groupURLsTable = "group_urls"
groupURLColumn = "url" groupURLColumn = "url"
groupRelationsTable = "groups_relations"
) )
type groupRow struct { type groupRow struct {
@ -128,6 +130,7 @@ var (
type GroupStore struct { type GroupStore struct {
blobJoinQueryBuilder blobJoinQueryBuilder
tagRelationshipStore tagRelationshipStore
groupRelationshipStore
tableMgr *table tableMgr *table
} }
@ -143,6 +146,9 @@ func NewGroupStore(blobStore *BlobStore) *GroupStore {
joinTable: groupsTagsTableMgr, joinTable: groupsTagsTableMgr,
}, },
}, },
groupRelationshipStore: groupRelationshipStore{
table: groupRelationshipTableMgr,
},
tableMgr: groupTableMgr, tableMgr: groupTableMgr,
} }
@ -176,6 +182,14 @@ func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error
return err return err
} }
if err := qb.groupRelationshipStore.createContainingRelationships(ctx, id, newObject.ContainingGroups); err != nil {
return err
}
if err := qb.groupRelationshipStore.createSubRelationships(ctx, id, newObject.SubGroups); err != nil {
return err
}
updated, err := qb.find(ctx, id) updated, err := qb.find(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("finding after create: %w", err) return fmt.Errorf("finding after create: %w", err)
@ -211,6 +225,14 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models.
return nil, err return nil, err
} }
if err := qb.groupRelationshipStore.modifyContainingRelationships(ctx, id, partial.ContainingGroups); err != nil {
return nil, err
}
if err := qb.groupRelationshipStore.modifySubRelationships(ctx, id, partial.SubGroups); err != nil {
return nil, err
}
return qb.find(ctx, id) return qb.find(ctx, id)
} }
@ -232,6 +254,14 @@ func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) e
return err return err
} }
if err := qb.groupRelationshipStore.replaceContainingRelationships(ctx, updatedObject.ID, updatedObject.ContainingGroups); err != nil {
return err
}
if err := qb.groupRelationshipStore.replaceSubRelationships(ctx, updatedObject.ID, updatedObject.SubGroups); err != nil {
return err
}
return nil return nil
} }
@ -412,9 +442,7 @@ func (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFi
return nil, err return nil, err
} }
var err error if err := qb.setGroupSort(&query, findFilter); err != nil {
query.sortAndPagination, err = qb.getGroupSort(findFilter)
if err != nil {
return nil, err return nil, err
} }
@ -460,11 +488,12 @@ var groupSortOptions = sortOptions{
"random", "random",
"rating", "rating",
"scenes_count", "scenes_count",
"sub_group_order",
"tag_count", "tag_count",
"updated_at", "updated_at",
} }
func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, error) { func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindFilterType) error {
var sort string var sort string
var direction string var direction string
if findFilter == nil { if findFilter == nil {
@ -477,22 +506,31 @@ func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, e
// CVE-2024-32231 - ensure sort is in the list of allowed sorts // CVE-2024-32231 - ensure sort is in the list of allowed sorts
if err := groupSortOptions.validateSort(sort); err != nil { if err := groupSortOptions.validateSort(sort); err != nil {
return "", err return err
} }
sortQuery := ""
switch sort { switch sort {
case "sub_group_order":
// sub_group_order is a special sort that sorts by the order_index of the subgroups
if query.hasJoin("groups_parents") {
query.sortAndPagination += getSort("order_index", direction, "groups_parents")
} else {
// this will give unexpected results if the query is not filtered by a parent group and
// the group has multiple parents and order indexes
query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
query.sortAndPagination += getSort("order_index", direction, groupRelationsTable)
}
case "tag_count": case "tag_count":
sortQuery += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction)
case "scenes_count": // generic getSort won't work for this case "scenes_count": // generic getSort won't work for this
sortQuery += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction)
default: default:
sortQuery += getSort(sort, direction, "groups") query.sortAndPagination += getSort(sort, direction, "groups")
} }
// Whatever the sorting, always use name/id as a final sort // Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC" query.sortAndPagination += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC"
return sortQuery, nil return nil
} }
func (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) { func (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) {
@ -592,3 +630,74 @@ WHERE groups.studio_id = ?
func (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) { func (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) {
return groupsURLsTableMgr.get(ctx, groupID) return groupsURLsTableMgr.get(ctx, groupID)
} }
// FindSubGroupIDs returns a list of group IDs where a group in the ids list is a sub-group of the parent group
func (qb *GroupStore) FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) {
/*
SELECT gr.sub_id FROM groups_relations gr
WHERE gr.containing_id = :parentID AND gr.sub_id IN (:ids);
*/
table := groupRelationshipTableMgr.table
q := dialect.From(table).Prepared(true).
Select(table.Col("sub_id")).Where(
table.Col("containing_id").Eq(containingID),
table.Col("sub_id").In(ids),
)
const single = false
var ret []int
if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
var id int
if err := r.Scan(&id); err != nil {
return err
}
ret = append(ret, id)
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
// FindInAscestors returns a list of group IDs where a group in the ids list is an ascestor of the ancestor group IDs
func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) {
/*
WITH RECURSIVE ascestors AS (
SELECT g.id AS parent_id FROM groups g WHERE g.id IN (:ascestorIDs)
UNION
SELECT gr.containing_id FROM groups_relations gr INNER JOIN ascestors a ON a.parent_id = gr.sub_id
)
SELECT p.parent_id FROM ascestors p WHERE p.parent_id IN (:ids);
*/
table := qb.table()
const ascestors = "ancestors"
const parentID = "parent_id"
q := dialect.From(ascestors).Prepared(true).
WithRecursive(ascestors,
dialect.From(qb.table()).Select(table.Col(idColumn).As(parentID)).
Where(table.Col(idColumn).In(ascestorIDs)).
Union(
dialect.From(groupRelationsJoinTable).InnerJoin(
goqu.I(ascestors), goqu.On(goqu.I("parent_id").Eq(goqu.I("sub_id"))),
).Select("containing_id"),
),
).Select(parentID).Where(goqu.I(parentID).In(ids))
const single = false
var ret []int
if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
var id int
if err := r.Scan(&id); err != nil {
return err
}
ret = append(ret, id)
return nil
}); err != nil {
return nil, err
}
return ret, nil
}

View file

@ -51,6 +51,14 @@ func (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) {
f.handleCriterion(ctx, qb.criterionHandler()) f.handleCriterion(ctx, qb.criterionHandler())
} }
var groupHierarchyHandler = hierarchicalRelationshipHandler{
primaryTable: groupTable,
relationTable: groupRelationsTable,
aliasPrefix: groupTable,
parentIDCol: "containing_id",
childIDCol: "sub_id",
}
func (qb *groupFilterHandler) criterionHandler() criterionHandler { func (qb *groupFilterHandler) criterionHandler() criterionHandler {
groupFilter := qb.groupFilter groupFilter := qb.groupFilter
return compoundHandler{ return compoundHandler{
@ -66,6 +74,10 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler {
qb.tagsCriterionHandler(groupFilter.Tags), qb.tagsCriterionHandler(groupFilter.Tags),
qb.tagCountCriterionHandler(groupFilter.TagCount), qb.tagCountCriterionHandler(groupFilter.TagCount),
&dateCriterionHandler{groupFilter.Date, "groups.date", nil}, &dateCriterionHandler{groupFilter.Date, "groups.date", nil},
groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups),
groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups),
groupHierarchyHandler.ParentCountCriterionHandler(groupFilter.ContainingGroupCount),
groupHierarchyHandler.ChildCountCriterionHandler(groupFilter.SubGroupCount),
&timestampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, &timestampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil},
&timestampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, &timestampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil},

View file

@ -0,0 +1,457 @@
package sqlite
import (
"context"
"fmt"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/models"
"gopkg.in/guregu/null.v4"
"gopkg.in/guregu/null.v4/zero"
)
type groupRelationshipRow struct {
ContainingID int `db:"containing_id"`
SubID int `db:"sub_id"`
OrderIndex int `db:"order_index"`
Description zero.String `db:"description"`
}
func (r groupRelationshipRow) resolve(useContainingID bool) models.GroupIDDescription {
id := r.ContainingID
if !useContainingID {
id = r.SubID
}
return models.GroupIDDescription{
GroupID: id,
Description: r.Description.String,
}
}
type groupRelationshipStore struct {
table *table
}
func (s *groupRelationshipStore) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {
const idIsContaining = false
return s.getGroupRelationships(ctx, id, idIsContaining)
}
func (s *groupRelationshipStore) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {
const idIsContaining = true
return s.getGroupRelationships(ctx, id, idIsContaining)
}
func (s *groupRelationshipStore) getGroupRelationships(ctx context.Context, id int, idIsContaining bool) ([]models.GroupIDDescription, error) {
col := "containing_id"
if !idIsContaining {
col = "sub_id"
}
table := s.table.table
q := dialect.Select(table.All()).
From(table).
Where(table.Col(col).Eq(id)).
Order(table.Col("order_index").Asc())
const single = false
var ret []models.GroupIDDescription
if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {
var row groupRelationshipRow
if err := rows.StructScan(&row); err != nil {
return err
}
ret = append(ret, row.resolve(!idIsContaining))
return nil
}); err != nil {
return nil, fmt.Errorf("getting group relationships from %s: %w", table.GetTable(), err)
}
return ret, nil
}
// getMaxOrderIndex gets the maximum order index for the containing group with the given id
func (s *groupRelationshipStore) getMaxOrderIndex(ctx context.Context, containingID int) (int, error) {
idColumn := s.table.table.Col("containing_id")
q := dialect.Select(goqu.MAX("order_index")).
From(s.table.table).
Where(idColumn.Eq(containingID))
var maxOrderIndex zero.Int
if err := querySimple(ctx, q, &maxOrderIndex); err != nil {
return 0, fmt.Errorf("getting max order index: %w", err)
}
return int(maxOrderIndex.Int64), nil
}
// createRelationships creates relationships between a group and other groups.
// If idIsContaining is true, the provided id is the containing group.
func (s *groupRelationshipStore) createRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error {
if d.Loaded() {
for i, v := range d.List() {
orderIndex := i + 1
r := groupRelationshipRow{
ContainingID: id,
SubID: v.GroupID,
OrderIndex: orderIndex,
Description: zero.StringFrom(v.Description),
}
if !idIsContaining {
// get the max order index of the containing groups sub groups
containingID := v.GroupID
maxOrderIndex, err := s.getMaxOrderIndex(ctx, containingID)
if err != nil {
return err
}
r.ContainingID = v.GroupID
r.SubID = id
r.OrderIndex = maxOrderIndex + 1
}
_, err := s.table.insert(ctx, r)
if err != nil {
return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err)
}
}
return nil
}
return nil
}
// createRelationships creates relationships between a group and other groups.
// If idIsContaining is true, the provided id is the containing group.
func (s *groupRelationshipStore) createContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {
const idIsContaining = false
return s.createRelationships(ctx, id, d, idIsContaining)
}
// createRelationships creates relationships between a group and other groups.
// If idIsContaining is true, the provided id is the containing group.
func (s *groupRelationshipStore) createSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {
const idIsContaining = true
return s.createRelationships(ctx, id, d, idIsContaining)
}
func (s *groupRelationshipStore) replaceRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error {
// always destroy the existing relationships even if the new list is empty
if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil {
return err
}
return s.createRelationships(ctx, id, d, idIsContaining)
}
func (s *groupRelationshipStore) replaceContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {
const idIsContaining = false
return s.replaceRelationships(ctx, id, d, idIsContaining)
}
func (s *groupRelationshipStore) replaceSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {
const idIsContaining = true
return s.replaceRelationships(ctx, id, d, idIsContaining)
}
func (s *groupRelationshipStore) modifyRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions, idIsContaining bool) error {
if v == nil {
return nil
}
switch v.Mode {
case models.RelationshipUpdateModeSet:
return s.replaceJoins(ctx, id, *v, idIsContaining)
case models.RelationshipUpdateModeAdd:
return s.addJoins(ctx, id, v.Groups, idIsContaining)
case models.RelationshipUpdateModeRemove:
toRemove := make([]int, len(v.Groups))
for i, vv := range v.Groups {
toRemove[i] = vv.GroupID
}
return s.destroyJoins(ctx, id, toRemove, idIsContaining)
}
return nil
}
func (s *groupRelationshipStore) modifyContainingRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error {
const idIsContaining = false
return s.modifyRelationships(ctx, id, v, idIsContaining)
}
func (s *groupRelationshipStore) modifySubRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error {
const idIsContaining = true
return s.modifyRelationships(ctx, id, v, idIsContaining)
}
func (s *groupRelationshipStore) addJoins(ctx context.Context, id int, groups []models.GroupIDDescription, idIsContaining bool) error {
// if we're adding to a containing group, get the max order index first
var maxOrderIndex int
if idIsContaining {
var err error
maxOrderIndex, err = s.getMaxOrderIndex(ctx, id)
if err != nil {
return err
}
}
for i, vv := range groups {
r := groupRelationshipRow{
Description: zero.StringFrom(vv.Description),
}
if idIsContaining {
r.ContainingID = id
r.SubID = vv.GroupID
r.OrderIndex = maxOrderIndex + (i + 1)
} else {
// get the max order index of the containing groups sub groups
containingMaxOrderIndex, err := s.getMaxOrderIndex(ctx, vv.GroupID)
if err != nil {
return err
}
r.ContainingID = vv.GroupID
r.SubID = id
r.OrderIndex = containingMaxOrderIndex + 1
}
_, err := s.table.insert(ctx, r)
if err != nil {
return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err)
}
}
return nil
}
func (s *groupRelationshipStore) destroyAllJoins(ctx context.Context, id int, idIsContaining bool) error {
table := s.table.table
idColumn := table.Col("containing_id")
if !idIsContaining {
idColumn = table.Col("sub_id")
}
q := dialect.Delete(table).Where(idColumn.Eq(id))
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("destroying %s: %w", table.GetTable(), err)
}
return nil
}
func (s *groupRelationshipStore) replaceJoins(ctx context.Context, id int, v models.UpdateGroupDescriptions, idIsContaining bool) error {
if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil {
return err
}
// convert to RelatedGroupDescriptions
rgd := models.NewRelatedGroupDescriptions(v.Groups)
return s.createRelationships(ctx, id, rgd, idIsContaining)
}
func (s *groupRelationshipStore) destroyJoins(ctx context.Context, id int, toRemove []int, idIsContaining bool) error {
table := s.table.table
idColumn := table.Col("containing_id")
fkColumn := table.Col("sub_id")
if !idIsContaining {
idColumn = table.Col("sub_id")
fkColumn = table.Col("containing_id")
}
q := dialect.Delete(table).Where(idColumn.Eq(id), fkColumn.In(toRemove))
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("destroying %s: %w", table.GetTable(), err)
}
return nil
}
func (s *groupRelationshipStore) getOrderIndexOfSubGroup(ctx context.Context, containingGroupID int, subGroupID int) (int, error) {
table := s.table.table
q := dialect.Select("order_index").
From(table).
Where(
table.Col("containing_id").Eq(containingGroupID),
table.Col("sub_id").Eq(subGroupID),
)
var orderIndex null.Int
if err := querySimple(ctx, q, &orderIndex); err != nil {
return 0, fmt.Errorf("getting order index: %w", err)
}
if !orderIndex.Valid {
return 0, fmt.Errorf("sub-group %d not found in containing group %d", subGroupID, containingGroupID)
}
return int(orderIndex.Int64), nil
}
func (s *groupRelationshipStore) getGroupIDAtOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (*int, error) {
table := s.table.table
q := dialect.Select(table.Col("sub_id")).From(table).Where(
table.Col("containing_id").Eq(containingGroupID),
table.Col("order_index").Eq(orderIndex),
)
var ret null.Int
if err := querySimple(ctx, q, &ret); err != nil {
return nil, fmt.Errorf("getting sub id for order index: %w", err)
}
if !ret.Valid {
return nil, nil
}
intRet := int(ret.Int64)
return &intRet, nil
}
func (s *groupRelationshipStore) getOrderIndexAfterOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (int, error) {
table := s.table.table
q := dialect.Select(goqu.MIN("order_index")).From(table).Where(
table.Col("containing_id").Eq(containingGroupID),
table.Col("order_index").Gt(orderIndex),
)
var ret null.Int
if err := querySimple(ctx, q, &ret); err != nil {
return 0, fmt.Errorf("getting order index: %w", err)
}
if !ret.Valid {
return orderIndex + 1, nil
}
return int(ret.Int64), nil
}
// incrementOrderIndexes increments the order_index value of all sub-groups in the containing group at or after the given index
func (s *groupRelationshipStore) incrementOrderIndexes(ctx context.Context, groupID int, indexBefore int) error {
table := s.table.table
// WORKAROUND - sqlite won't allow incrementing the value directly since it causes a
// unique constraint violation.
// Instead, we first set the order index to a negative value temporarily
// see https://stackoverflow.com/a/7703239/695786
q := dialect.Update(table).Set(exp.Record{
"order_index": goqu.L("-order_index"),
}).Where(
table.Col("containing_id").Eq(groupID),
table.Col("order_index").Gte(indexBefore),
)
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("updating %s: %w", table.GetTable(), err)
}
q = dialect.Update(table).Set(exp.Record{
"order_index": goqu.L("1-order_index"),
}).Where(
table.Col("containing_id").Eq(groupID),
table.Col("order_index").Lt(0),
)
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("updating %s: %w", table.GetTable(), err)
}
return nil
}
func (s *groupRelationshipStore) reorderSubGroup(ctx context.Context, groupID int, subGroupID int, insertPointID int, insertAfter bool) error {
insertPointIndex, err := s.getOrderIndexOfSubGroup(ctx, groupID, insertPointID)
if err != nil {
return err
}
// if we're setting before
if insertAfter {
insertPointIndex, err = s.getOrderIndexAfterOrderIndex(ctx, groupID, insertPointIndex)
if err != nil {
return err
}
}
// increment the order index of all sub-groups after and including the insertion point
if err := s.incrementOrderIndexes(ctx, groupID, int(insertPointIndex)); err != nil {
return err
}
// set the order index of the sub-group to the insertion point
table := s.table.table
q := dialect.Update(table).Set(exp.Record{
"order_index": insertPointIndex,
}).Where(
table.Col("containing_id").Eq(groupID),
table.Col("sub_id").Eq(subGroupID),
)
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("updating %s: %w", table.GetTable(), err)
}
return nil
}
func (s *groupRelationshipStore) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error {
const idIsContaining = true
if err := s.addJoins(ctx, groupID, subGroups, idIsContaining); err != nil {
return err
}
ids := make([]int, len(subGroups))
for i, v := range subGroups {
ids[i] = v.GroupID
}
if insertIndex != nil {
// get the id of the sub-group at the insert index
insertPointID, err := s.getGroupIDAtOrderIndex(ctx, groupID, *insertIndex)
if err != nil {
return err
}
if insertPointID == nil {
// if the insert index is out of bounds, just assume adding to the end
return nil
}
// reorder the sub-groups
const insertAfter = false
if err := s.ReorderSubGroups(ctx, groupID, ids, *insertPointID, insertAfter); err != nil {
return err
}
}
return nil
}
func (s *groupRelationshipStore) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error {
const idIsContaining = true
return s.destroyJoins(ctx, groupID, subGroupIDs, idIsContaining)
}
func (s *groupRelationshipStore) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error {
for _, id := range subGroupIDs {
if err := s.reorderSubGroup(ctx, groupID, id, insertPointID, insertAfter); err != nil {
return err
}
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
CREATE TABLE `groups_relations` (
`containing_id` integer not null,
`sub_id` integer not null,
`order_index` integer not null,
`description` varchar(255),
primary key (`containing_id`, `sub_id`),
foreign key (`containing_id`) references `groups`(`id`) on delete cascade,
foreign key (`sub_id`) references `groups`(`id`) on delete cascade,
check (`containing_id` != `sub_id`)
);
CREATE INDEX `index_groups_relations_sub_id` ON `groups_relations` (`sub_id`);
CREATE UNIQUE INDEX `index_groups_relations_order_index_unique` ON `groups_relations` (`containing_id`, `order_index`);

View file

@ -110,6 +110,16 @@ func (qb *queryBuilder) addArg(args ...interface{}) {
qb.args = append(qb.args, args...) qb.args = append(qb.args, args...)
} }
func (qb *queryBuilder) hasJoin(alias string) bool {
for _, j := range qb.joins {
if j.alias() == alias {
return true
}
}
return false
}
func (qb *queryBuilder) join(table, as, onClause string) { func (qb *queryBuilder) join(table, as, onClause string) {
newJoin := join{ newJoin := join{
table: table, table: table,

View file

@ -791,13 +791,6 @@ func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models
return ret, nil return ret, nil
} }
func (qb *SceneStore) CountByGroupID(ctx context.Context, groupID int) (int, error) {
joinTable := scenesGroupsJoinTable
q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(groupIDColumn).Eq(groupID))
return count(ctx, q)
}
func (qb *SceneStore) Count(ctx context.Context) (int, error) { func (qb *SceneStore) Count(ctx context.Context) (int, error) {
q := dialect.Select(goqu.COUNT("*")).From(qb.table()) q := dialect.Select(goqu.COUNT("*")).From(qb.table())
return count(ctx, q) return count(ctx, q)
@ -858,6 +851,7 @@ func (qb *SceneStore) PlayDuration(ctx context.Context) (float64, error) {
return ret, nil return ret, nil
} }
// TODO - currently only used by unit test
func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, error) {
table := qb.table() table := qb.table()
@ -865,13 +859,6 @@ func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, e
return count(ctx, q) return count(ctx, q)
} }
func (qb *SceneStore) CountByTagID(ctx context.Context, tagID int) (int, error) {
joinTable := scenesTagsJoinTable
q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID))
return count(ctx, q)
}
func (qb *SceneStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) { func (qb *SceneStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) {
fpTable := fingerprintTableMgr.table.As("fingerprints_temp") fpTable := fingerprintTableMgr.table.As("fingerprints_temp")

View file

@ -149,7 +149,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
studioCriterionHandler(sceneTable, sceneFilter.Studios), studioCriterionHandler(sceneTable, sceneFilter.Studios),
qb.groupsCriterionHandler(sceneFilter.Groups), qb.groupsCriterionHandler(sceneFilter.Groups),
qb.groupsCriterionHandler(sceneFilter.Movies), qb.moviesCriterionHandler(sceneFilter.Movies),
qb.galleriesCriterionHandler(sceneFilter.Galleries), qb.galleriesCriterionHandler(sceneFilter.Galleries),
qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), qb.performerTagsCriterionHandler(sceneFilter.PerformerTags),
@ -483,7 +483,8 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.
} }
} }
func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { // legacy handler
func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder) {
sceneRepository.groups.join(f, "", "scenes.id") sceneRepository.groups.join(f, "", "scenes.id")
f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id")
@ -492,6 +493,23 @@ func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriteri
return h.handler(movies) return h.handler(movies)
} }
func (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := joinedHierarchicalMultiCriterionHandlerBuilder{
primaryTable: sceneTable,
foreignTable: groupTable,
foreignFK: "group_id",
relationsTable: groupRelationsTable,
parentFK: "containing_id",
childFK: "sub_id",
joinAs: "scene_group",
joinTable: groupsScenesTable,
primaryFK: sceneIDColumn,
}
return h.handler(groups)
}
func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder) {
sceneRepository.galleries.join(f, "", "scenes.id") sceneRepository.galleries.join(f, "", "scenes.id")

View file

@ -16,6 +16,7 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -2217,7 +2218,7 @@ func TestSceneQuery(t *testing.T) {
}, },
}) })
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
@ -3873,6 +3874,100 @@ func TestSceneQueryStudioDepth(t *testing.T) {
}) })
} }
func TestSceneGroups(t *testing.T) {
type criterion struct {
valueIdxs []int
modifier models.CriterionModifier
depth int
}
tests := []struct {
name string
c criterion
q string
includeIdxs []int
excludeIdxs []int
}{
{
"includes",
criterion{
[]int{groupIdxWithScene},
models.CriterionModifierIncludes,
0,
},
"",
[]int{sceneIdxWithGroup},
nil,
},
{
"excludes",
criterion{
[]int{groupIdxWithScene},
models.CriterionModifierExcludes,
0,
},
getSceneStringValue(sceneIdxWithGroup, titleField),
nil,
[]int{sceneIdxWithGroup},
},
{
"includes (depth = 1)",
criterion{
[]int{groupIdxWithChildWithScene},
models.CriterionModifierIncludes,
1,
},
"",
[]int{sceneIdxWithGroupWithParent},
nil,
},
}
for _, tt := range tests {
valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
sceneFilter := &models.SceneFilterType{
Groups: &models.HierarchicalMultiCriterionInput{
Value: intslice.IntSliceToStringSlice(valueIDs),
Modifier: tt.c.modifier,
},
}
if tt.c.depth != 0 {
sceneFilter.Groups.Depth = &tt.c.depth
}
findFilter := &models.FindFilterType{}
if tt.q != "" {
findFilter.Q = &tt.q
}
results, err := db.Scene.Query(ctx, models.SceneQueryOptions{
SceneFilter: sceneFilter,
QueryOptions: models.QueryOptions{
FindFilter: findFilter,
},
})
if err != nil {
t.Errorf("SceneStore.Query() error = %v", err)
return
}
include := indexesToIDs(sceneIDs, tt.includeIdxs)
exclude := indexesToIDs(sceneIDs, tt.excludeIdxs)
assert.Subset(results.IDs, include)
for _, e := range exclude {
assert.NotContains(results.IDs, e)
}
})
}
}
func TestSceneQueryMovies(t *testing.T) { func TestSceneQueryMovies(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Scene sqb := db.Scene
@ -4188,78 +4283,6 @@ func verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.Int
}) })
} }
func TestSceneCountByTagID(t *testing.T) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneCount, err := sqb.CountByTagID(ctx, tagIDs[tagIdxWithScene])
if err != nil {
t.Errorf("error calling CountByTagID: %s", err.Error())
}
assert.Equal(t, 1, sceneCount)
sceneCount, err = sqb.CountByTagID(ctx, 0)
if err != nil {
t.Errorf("error calling CountByTagID: %s", err.Error())
}
assert.Equal(t, 0, sceneCount)
return nil
})
}
func TestSceneCountByGroupID(t *testing.T) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneCount, err := sqb.CountByGroupID(ctx, groupIDs[groupIdxWithScene])
if err != nil {
t.Errorf("error calling CountByGroupID: %s", err.Error())
}
assert.Equal(t, 1, sceneCount)
sceneCount, err = sqb.CountByGroupID(ctx, 0)
if err != nil {
t.Errorf("error calling CountByGroupID: %s", err.Error())
}
assert.Equal(t, 0, sceneCount)
return nil
})
}
func TestSceneCountByStudioID(t *testing.T) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneCount, err := sqb.CountByStudioID(ctx, studioIDs[studioIdxWithScene])
if err != nil {
t.Errorf("error calling CountByStudioID: %s", err.Error())
}
assert.Equal(t, 1, sceneCount)
sceneCount, err = sqb.CountByStudioID(ctx, 0)
if err != nil {
t.Errorf("error calling CountByStudioID: %s", err.Error())
}
assert.Equal(t, 0, sceneCount)
return nil
})
}
func TestFindByMovieID(t *testing.T) { func TestFindByMovieID(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Scene sqb := db.Scene

View file

@ -78,6 +78,7 @@ const (
sceneIdxWithGrandChildStudio sceneIdxWithGrandChildStudio
sceneIdxMissingPhash sceneIdxMissingPhash
sceneIdxWithPerformerParentTag sceneIdxWithPerformerParentTag
sceneIdxWithGroupWithParent
// new indexes above // new indexes above
lastSceneIdx lastSceneIdx
@ -153,9 +154,15 @@ const (
groupIdxWithTag groupIdxWithTag
groupIdxWithTwoTags groupIdxWithTwoTags
groupIdxWithThreeTags groupIdxWithThreeTags
groupIdxWithGrandChild
groupIdxWithChild
groupIdxWithParentAndChild
groupIdxWithParent
groupIdxWithGrandParent
groupIdxWithParentAndScene
groupIdxWithChildWithScene
// groups with dup names start from the end // groups with dup names start from the end
// create 7 more basic groups (can remove this if we add more indexes) groupIdxWithDupName
groupIdxWithDupName = groupIdxWithStudio + 7
groupsNameCase = groupIdxWithDupName groupsNameCase = groupIdxWithDupName
groupsNameNoCase = 1 groupsNameNoCase = 1
@ -390,7 +397,8 @@ var (
} }
sceneGroups = linkMap{ sceneGroups = linkMap{
sceneIdxWithGroup: {groupIdxWithScene}, sceneIdxWithGroup: {groupIdxWithScene},
sceneIdxWithGroupWithParent: {groupIdxWithParentAndScene},
} }
sceneStudios = map[int]int{ sceneStudios = map[int]int{
@ -541,15 +549,31 @@ var (
} }
) )
var (
groupParentLinks = [][2]int{
{groupIdxWithChild, groupIdxWithParent},
{groupIdxWithGrandChild, groupIdxWithParentAndChild},
{groupIdxWithParentAndChild, groupIdxWithGrandParent},
{groupIdxWithChildWithScene, groupIdxWithParentAndScene},
}
)
func indexesToIDs(ids []int, indexes []int) []int { func indexesToIDs(ids []int, indexes []int) []int {
ret := make([]int, len(indexes)) ret := make([]int, len(indexes))
for i, idx := range indexes { for i, idx := range indexes {
ret[i] = ids[idx] ret[i] = indexToID(ids, idx)
} }
return ret return ret
} }
func indexToID(ids []int, idx int) int {
if idx < 0 {
return invalidID
}
return ids[idx]
}
func indexFromID(ids []int, id int) int { func indexFromID(ids []int, id int) int {
for i, v := range ids { for i, v := range ids {
if v == id { if v == id {
@ -697,6 +721,10 @@ func populateDB() error {
return fmt.Errorf("error linking tags parent: %s", err.Error()) return fmt.Errorf("error linking tags parent: %s", err.Error())
} }
if err := linkGroupsParent(ctx, db.Group); err != nil {
return fmt.Errorf("error linking tags parent: %s", err.Error())
}
for _, ms := range markerSpecs { for _, ms := range markerSpecs {
if err := createMarker(ctx, db.SceneMarker, ms); err != nil { if err := createMarker(ctx, db.SceneMarker, ms); err != nil {
return fmt.Errorf("error creating scene marker: %s", err.Error()) return fmt.Errorf("error creating scene marker: %s", err.Error())
@ -1885,6 +1913,24 @@ func linkTagsParent(ctx context.Context, qb models.TagReaderWriter) error {
}) })
} }
func linkGroupsParent(ctx context.Context, qb models.GroupReaderWriter) error {
return doLinks(groupParentLinks, func(parentIndex, childIndex int) error {
groupID := groupIDs[childIndex]
p := models.GroupPartial{
ContainingGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[parentIndex]},
},
Mode: models.RelationshipUpdateModeAdd,
},
}
_, err := qb.UpdatePartial(ctx, groupID, p)
return err
})
}
func addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error { func addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error {
return qb.UpdateImage(ctx, tagIDs[tagIndex], []byte("image")) return qb.UpdateImage(ctx, tagIDs[tagIndex], []byte("image"))
} }

View file

@ -37,8 +37,9 @@ var (
studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable)
studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
groupsURLsJoinTable = goqu.T(groupURLsTable) groupsURLsJoinTable = goqu.T(groupURLsTable)
groupsTagsJoinTable = goqu.T(groupsTagsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable)
groupRelationsJoinTable = goqu.T(groupRelationsTable)
tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable)
tagRelationsJoinTable = goqu.T(tagRelationsTable) tagRelationsJoinTable = goqu.T(tagRelationsTable)
@ -361,6 +362,10 @@ var (
foreignTable: tagTableMgr, foreignTable: tagTableMgr,
orderBy: tagTableMgr.table.Col("name").Asc(), orderBy: tagTableMgr.table.Col("name").Asc(),
} }
groupRelationshipTableMgr = &table{
table: groupRelationsJoinTable,
}
) )
var ( var (

View file

@ -2,7 +2,6 @@ package sqlite
import ( import (
"context" "context"
"fmt"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@ -51,6 +50,14 @@ func (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) {
f.handleCriterion(ctx, qb.criterionHandler()) f.handleCriterion(ctx, qb.criterionHandler())
} }
var tagHierarchyHandler = hierarchicalRelationshipHandler{
primaryTable: tagTable,
relationTable: tagRelationsTable,
aliasPrefix: tagTable,
parentIDCol: "parent_id",
childIDCol: "child_id",
}
func (qb *tagFilterHandler) criterionHandler() criterionHandler { func (qb *tagFilterHandler) criterionHandler() criterionHandler {
tagFilter := qb.tagFilter tagFilter := qb.tagFilter
return compoundHandler{ return compoundHandler{
@ -72,10 +79,10 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
qb.groupCountCriterionHandler(tagFilter.MovieCount), qb.groupCountCriterionHandler(tagFilter.MovieCount),
qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount),
qb.parentsCriterionHandler(tagFilter.Parents), tagHierarchyHandler.ParentsCriterionHandler(tagFilter.Parents),
qb.childrenCriterionHandler(tagFilter.Children), tagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children),
qb.parentCountCriterionHandler(tagFilter.ParentCount), tagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount),
qb.childCountCriterionHandler(tagFilter.ChildCount), tagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount),
&timestampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, &timestampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
&timestampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, &timestampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},
@ -212,213 +219,3 @@ func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntC
} }
} }
} }
func (qb *tagFilterHandler) parentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if criterion != nil {
tags := criterion.CombineExcludes()
// validate the modifier
switch tags.Modifier {
case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull:
// valid
default:
f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier))
}
if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull {
var notClause string
if tags.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
}
f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id")
f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause))
return
}
if len(tags.Value) == 0 && len(tags.Excludes) == 0 {
return
}
if len(tags.Value) > 0 {
var args []interface{}
for _, val := range tags.Value {
args = append(args, val)
}
depthVal := 0
if tags.Depth != nil {
depthVal = *tags.Depth
}
var depthCondition string
if depthVal != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
}
query := `parents AS (
SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + `
UNION
SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + `
)`
f.addRecursiveWith(query, args...)
f.addLeftJoin("parents", "", "parents.item_id = tags.id")
addHierarchicalConditionClauses(f, tags, "parents", "root_id")
}
if len(tags.Excludes) > 0 {
var args []interface{}
for _, val := range tags.Excludes {
args = append(args, val)
}
depthVal := 0
if tags.Depth != nil {
depthVal = *tags.Depth
}
var depthCondition string
if depthVal != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
}
query := `parents2 AS (
SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + `
UNION
SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + `
)`
f.addRecursiveWith(query, args...)
f.addLeftJoin("parents2", "", "parents2.item_id = tags.id")
addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{
Value: tags.Excludes,
Depth: tags.Depth,
Modifier: models.CriterionModifierExcludes,
}, "parents2", "root_id")
}
}
}
}
func (qb *tagFilterHandler) childrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if criterion != nil {
tags := criterion.CombineExcludes()
// validate the modifier
switch tags.Modifier {
case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull:
// valid
default:
f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier))
}
if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull {
var notClause string
if tags.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
}
f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id")
f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause))
return
}
if len(tags.Value) == 0 && len(tags.Excludes) == 0 {
return
}
if len(tags.Value) > 0 {
var args []interface{}
for _, val := range tags.Value {
args = append(args, val)
}
depthVal := 0
if tags.Depth != nil {
depthVal = *tags.Depth
}
var depthCondition string
if depthVal != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
}
query := `children AS (
SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + `
UNION
SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + `
)`
f.addRecursiveWith(query, args...)
f.addLeftJoin("children", "", "children.item_id = tags.id")
addHierarchicalConditionClauses(f, tags, "children", "root_id")
}
if len(tags.Excludes) > 0 {
var args []interface{}
for _, val := range tags.Excludes {
args = append(args, val)
}
depthVal := 0
if tags.Depth != nil {
depthVal = *tags.Depth
}
var depthCondition string
if depthVal != -1 {
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
}
query := `children2 AS (
SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + `
UNION
SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + `
)`
f.addRecursiveWith(query, args...)
f.addLeftJoin("children2", "", "children2.item_id = tags.id")
addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{
Value: tags.Excludes,
Depth: tags.Depth,
Modifier: models.CriterionModifierExcludes,
}, "children2", "root_id")
}
}
}
}
func (qb *tagFilterHandler) parentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if parentCount != nil {
f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount)
f.addHaving(clause, args...)
}
}
}
func (qb *tagFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if childCount != nil {
f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount)
f.addHaving(clause, args...)
}
}
}

View file

@ -712,7 +712,7 @@ func TestTagQueryParent(t *testing.T) {
assert.Len(t, tags, 1) assert.Len(t, tags, 1)
// ensure id is correct // ensure id is correct
assert.Equal(t, sceneIDs[tagIdxWithParentTag], tags[0].ID) assert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID)
tagCriterion.Modifier = models.CriterionModifierExcludes tagCriterion.Modifier = models.CriterionModifierExcludes

View file

@ -15,11 +15,21 @@ fragment GroupData on Group {
...SlimTagData ...SlimTagData
} }
containing_groups {
group {
...SlimGroupData
}
description
}
synopsis synopsis
urls urls
front_image_path front_image_path
back_image_path back_image_path
scene_count scene_count
scene_count_all: scene_count(depth: -1)
sub_group_count
sub_group_count_all: sub_group_count(depth: -1)
scenes { scenes {
id id

View file

@ -23,3 +23,15 @@ mutation GroupDestroy($id: ID!) {
mutation GroupsDestroy($ids: [ID!]!) { mutation GroupsDestroy($ids: [ID!]!) {
groupsDestroy(ids: $ids) groupsDestroy(ids: $ids)
} }
mutation AddGroupSubGroups($input: GroupSubGroupAddInput!) {
addGroupSubGroups(input: $input)
}
mutation RemoveGroupSubGroups($input: GroupSubGroupRemoveInput!) {
removeGroupSubGroups(input: $input)
}
mutation ReorderSubGroups($input: ReorderSubGroupsInput!) {
reorderSubGroups(input: $input)
}

View file

@ -0,0 +1,61 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { MultiSetModeButtons } from "../Shared/MultiSet";
import {
IRelatedGroupEntry,
RelatedGroupTable,
} from "./GroupDetails/RelatedGroupTable";
import { Group, GroupSelect } from "./GroupSelect";
export const ContainingGroupsMultiSet: React.FC<{
existingValue?: IRelatedGroupEntry[];
value: IRelatedGroupEntry[];
mode: GQL.BulkUpdateIdMode;
disabled?: boolean;
onUpdate: (value: IRelatedGroupEntry[]) => void;
onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
}> = (props) => {
const { mode, onUpdate, existingValue } = props;
function onSetMode(m: GQL.BulkUpdateIdMode) {
if (m === mode) {
return;
}
// if going to Set, set the existing ids
if (m === GQL.BulkUpdateIdMode.Set && existingValue) {
onUpdate(existingValue);
// if going from Set, wipe the ids
} else if (
m !== GQL.BulkUpdateIdMode.Set &&
mode === GQL.BulkUpdateIdMode.Set
) {
onUpdate([]);
}
props.onSetMode(m);
}
function onRemoveSet(items: Group[]) {
onUpdate(items.map((group) => ({ group })));
}
return (
<div className="multi-set">
<MultiSetModeButtons mode={mode} onSetMode={onSetMode} />
{mode !== GQL.BulkUpdateIdMode.Remove ? (
<RelatedGroupTable
value={props.value}
onUpdate={props.onUpdate}
disabled={props.disabled}
/>
) : (
<GroupSelect
onSelect={(items) => onRemoveSet(items)}
values={[]}
isDisabled={props.disabled}
/>
)}
</div>
);
};

View file

@ -9,6 +9,7 @@ import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form"; import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateIds,
getAggregateInputIDs, getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
getAggregateRating, getAggregateRating,
@ -18,12 +19,54 @@ import {
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { MultiSet } from "../Shared/MultiSet"; import { MultiSet } from "../Shared/MultiSet";
import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.GroupDataFragment[]; selected: GQL.GroupDataFragment[];
onClose: (applied: boolean) => void; onClose: (applied: boolean) => void;
} }
export function getAggregateContainingGroups(
state: Pick<GQL.GroupDataFragment, "containing_groups">[]
) {
const sortedLists: IRelatedGroupEntry[][] = state.map((o) =>
o.containing_groups
.map((oo) => ({
group: oo.group,
description: oo.description,
}))
.sort((a, b) => a.group.id.localeCompare(b.group.id))
);
return getAggregateIds(sortedLists);
}
function getAggregateContainingGroupInput(
mode: GQL.BulkUpdateIdMode,
input: IRelatedGroupEntry[] | undefined,
aggregateValues: IRelatedGroupEntry[]
): GQL.BulkUpdateGroupDescriptionsInput | undefined {
if (mode === GQL.BulkUpdateIdMode.Set && (!input || input.length === 0)) {
// and all scenes have the same ids,
if (aggregateValues.length > 0) {
// then unset, otherwise ignore
return { mode, groups: [] };
}
} else {
// if input non-empty, then we are setting them
return {
mode,
groups:
input?.map((e) => {
return { group_id: e.group.id, description: e.description };
}) || [],
};
}
return undefined;
}
export const EditGroupsDialog: React.FC<IListOperationProps> = ( export const EditGroupsDialog: React.FC<IListOperationProps> = (
props: IListOperationProps props: IListOperationProps
) => { ) => {
@ -39,6 +82,12 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>(); const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [containingGroupsMode, setGroupMode] =
React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [containingGroups, setGroups] = useState<IRelatedGroupEntry[]>();
const [existingContainingGroups, setExistingContainingGroups] =
useState<IRelatedGroupEntry[]>();
const [updateGroups] = useBulkGroupUpdate(getGroupInput()); const [updateGroups] = useBulkGroupUpdate(getGroupInput());
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -47,17 +96,23 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
const aggregateRating = getAggregateRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected);
const aggregateGroups = getAggregateContainingGroups(props.selected);
const groupInput: GQL.BulkGroupUpdateInput = { const groupInput: GQL.BulkGroupUpdateInput = {
ids: props.selected.map((group) => group.id), ids: props.selected.map((group) => group.id),
director, director,
}; };
// if rating is undefined
groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
groupInput.containing_groups = getAggregateContainingGroupInput(
containingGroupsMode,
containingGroups,
aggregateGroups
);
return groupInput; return groupInput;
} }
@ -85,17 +140,22 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
let updateRating: number | undefined; let updateRating: number | undefined;
let updateStudioId: string | undefined; let updateStudioId: string | undefined;
let updateTagIds: string[] = []; let updateTagIds: string[] = [];
let updateContainingGroupIds: IRelatedGroupEntry[] = [];
let updateDirector: string | undefined; let updateDirector: string | undefined;
let first = true; let first = true;
state.forEach((group: GQL.GroupDataFragment) => { state.forEach((group: GQL.GroupDataFragment) => {
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
(a, b) => a.group.id.localeCompare(b.group.id)
);
if (first) { if (first) {
first = false; first = false;
updateRating = group.rating100 ?? undefined; updateRating = group.rating100 ?? undefined;
updateStudioId = group.studio?.id ?? undefined; updateStudioId = group.studio?.id ?? undefined;
updateTagIds = groupTagIDs; updateTagIds = groupTagIDs;
updateContainingGroupIds = groupContainingGroupIDs;
updateDirector = group.director ?? undefined; updateDirector = group.director ?? undefined;
} else { } else {
if (group.rating100 !== updateRating) { if (group.rating100 !== updateRating) {
@ -110,12 +170,16 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
if (!isEqual(groupTagIDs, updateTagIds)) { if (!isEqual(groupTagIDs, updateTagIds)) {
updateTagIds = []; updateTagIds = [];
} }
if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) {
updateTagIds = [];
}
} }
}); });
setRating(updateRating); setRating(updateRating);
setStudioId(updateStudioId); setStudioId(updateStudioId);
setExistingTagIds(updateTagIds); setExistingTagIds(updateTagIds);
setExistingContainingGroups(updateContainingGroupIds);
setDirector(updateDirector); setDirector(updateDirector);
}, [props.selected]); }, [props.selected]);
@ -166,6 +230,19 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="containing-groups">
<Form.Label>
<FormattedMessage id="containing_groups" />
</Form.Label>
<ContainingGroupsMultiSet
disabled={isUpdating}
onUpdate={(v) => setGroups(v)}
onSetMode={(newMode) => setGroupMode(newMode)}
existingValue={existingContainingGroups ?? []}
value={containingGroups ?? []}
mode={containingGroupsMode}
/>
</Form.Group>
<Form.Group controlId="director"> <Form.Group controlId="director">
<Form.Label> <Form.Label>
<FormattedMessage id="director" /> <FormattedMessage id="director" />

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
@ -10,26 +10,66 @@ import { FormattedMessage } from "react-intl";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
import { RelatedGroupPopoverButton } from "./RelatedGroupPopover";
const Description: React.FC<{
sceneNumber?: number;
description?: string;
}> = ({ sceneNumber, description }) => {
if (!sceneNumber && !description) return null;
return (
<>
<hr />
{sceneNumber !== undefined && (
<span className="group-scene-number">
<FormattedMessage id="scene" /> #{sceneNumber}
</span>
)}
{description !== undefined && (
<span className="group-containing-group-description">
{description}
</span>
)}
</>
);
};
interface IProps { interface IProps {
group: GQL.GroupDataFragment; group: GQL.GroupDataFragment;
containerWidth?: number; containerWidth?: number;
sceneIndex?: number; sceneNumber?: number;
selecting?: boolean; selecting?: boolean;
selected?: boolean; selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
} }
export const GroupCard: React.FC<IProps> = ({ export const GroupCard: React.FC<IProps> = ({
group, group,
sceneIndex, sceneNumber,
containerWidth, containerWidth,
selecting, selecting,
selected, selected,
onSelectedChanged, onSelectedChanged,
fromGroupId,
onMove,
}) => { }) => {
const [cardWidth, setCardWidth] = useState<number>(); const [cardWidth, setCardWidth] = useState<number>();
const groupDescription = useMemo(() => {
if (!fromGroupId) {
return undefined;
}
const containingGroup = group.containing_groups.find(
(cg) => cg.group.id === fromGroupId
);
return containingGroup?.description ?? undefined;
}, [fromGroupId, group.containing_groups]);
useEffect(() => { useEffect(() => {
if (!containerWidth || ScreenUtils.isMobile()) return; if (!containerWidth || ScreenUtils.isMobile()) return;
@ -41,19 +81,6 @@ export const GroupCard: React.FC<IProps> = ({
setCardWidth(fittedCardWidth); setCardWidth(fittedCardWidth);
}, [containerWidth]); }, [containerWidth]);
function maybeRenderSceneNumber() {
if (!sceneIndex) return;
return (
<>
<hr />
<span className="group-scene-number">
<FormattedMessage id="scene" /> #{sceneIndex}
</span>
</>
);
}
function maybeRenderScenesPopoverButton() { function maybeRenderScenesPopoverButton() {
if (group.scenes.length === 0) return; if (group.scenes.length === 0) return;
@ -93,14 +120,28 @@ export const GroupCard: React.FC<IProps> = ({
} }
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (sceneIndex || group.scenes.length > 0 || group.tags.length > 0) { if (
sceneNumber ||
groupDescription ||
group.scenes.length > 0 ||
group.tags.length > 0 ||
group.containing_groups.length > 0 ||
group.sub_group_count > 0
) {
return ( return (
<> <>
{maybeRenderSceneNumber()} <Description
sceneNumber={sceneNumber}
description={groupDescription}
/>
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
{(group.sub_group_count > 0 ||
group.containing_groups.length > 0) && (
<RelatedGroupPopoverButton group={group} />
)}
</ButtonGroup> </ButtonGroup>
</> </>
); );
@ -110,6 +151,8 @@ export const GroupCard: React.FC<IProps> = ({
return ( return (
<GridCard <GridCard
className="group-card" className="group-card"
objectId={group.id}
onMove={onMove}
url={`/groups/${group.id}`} url={`/groups/${group.id}`}
width={cardWidth} width={cardWidth}
title={group.name} title={group.name}

View file

@ -7,12 +7,16 @@ interface IGroupCardGrid {
groups: GQL.GroupDataFragment[]; groups: GQL.GroupDataFragment[];
selectedIds: Set<string>; selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
} }
export const GroupCardGrid: React.FC<IGroupCardGrid> = ({ export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
groups, groups,
selectedIds, selectedIds,
onSelectChange, onSelectChange,
fromGroupId,
onMove,
}) => { }) => {
const [componentRef, { width }] = useContainerDimensions(); const [componentRef, { width }] = useContainerDimensions();
return ( return (
@ -27,6 +31,8 @@ export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey) onSelectChange(p.id, selected, shiftKey)
} }
fromGroupId={fromGroupId}
onMove={onMove}
/> />
))} ))}
</div> </div>

View file

@ -0,0 +1,121 @@
import React, { useCallback, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { useToast } from "src/hooks/Toast";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
import { ModalComponent } from "src/components/Shared/Modal";
import { useAddSubGroups } from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
ContainingGroupsCriterionOption,
GroupsCriterion,
} from "src/models/list-filter/criteria/groups";
interface IListOperationProps {
containingGroup: GQL.GroupDataFragment;
onClose: (applied: boolean) => void;
}
export const AddSubGroupsDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const addSubGroups = useAddSubGroups();
const Toast = useToast();
const [entries, setEntries] = useState<IRelatedGroupEntry[]>([]);
const excludeIDs = useMemo(
() => [
...props.containingGroup.containing_groups.map((m) => m.group.id),
props.containingGroup.id,
],
[props.containingGroup]
);
const filterHook = useCallback(
(f: ListFilterModel) => {
const groupValue = {
id: props.containingGroup.id,
label: props.containingGroup.name,
};
// filter out sub groups that are already in the containing group
const criterion = new GroupsCriterion(ContainingGroupsCriterionOption);
criterion.value = {
items: [groupValue],
depth: 1,
excluded: [],
};
criterion.modifier = GQL.CriterionModifier.Excludes;
f.criteria.push(criterion);
return f;
},
[props.containingGroup]
);
const onSave = async () => {
setIsUpdating(true);
try {
// add the sub groups
await addSubGroups(
props.containingGroup.id,
entries.map((m) => ({
group_id: m.group.id,
description: m.description,
}))
);
const imageCount = entries.length;
Toast.success(
intl.formatMessage(
{ id: "toast.added_entity" },
{
count: imageCount,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)
);
props.onClose(true);
} catch (err) {
Toast.error(err);
} finally {
setIsUpdating(false);
}
};
return (
<ModalComponent
show
icon={faPlus}
header={intl.formatMessage({ id: "actions.add_sub_groups" })}
accept={{
onClick: onSave,
text: intl.formatMessage({ id: "actions.add" }),
}}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={isUpdating}
>
<Form>
<RelatedGroupTable
value={entries}
onUpdate={(input) => setEntries(input)}
excludeIDs={excludeIDs}
filterHook={filterHook}
/>
</Form>
</ModalComponent>
);
};

View file

@ -9,7 +9,7 @@ import {
useGroupUpdate, useGroupUpdate,
useGroupDestroy, useGroupDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { useHistory, RouteComponentProps } from "react-router-dom"; import { useHistory, RouteComponentProps, Redirect } from "react-router-dom";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
@ -35,16 +35,89 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
import {
TabTitleCounter,
useTabKey,
} from "src/components/Shared/DetailsPage/Tabs";
import { Tab, Tabs } from "react-bootstrap";
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
const validTabs = ["default", "scenes", "subgroups"] as const;
type TabKey = (typeof validTabs)[number];
function isTabKey(tab: string): tab is TabKey {
return validTabs.includes(tab as TabKey);
}
const GroupTabs: React.FC<{
tabKey?: TabKey;
group: GQL.GroupDataFragment;
abbreviateCounter: boolean;
}> = ({ tabKey, group, abbreviateCounter }) => {
const { scene_count: sceneCount, sub_group_count: groupCount } = group;
const populatedDefaultTab = useMemo(() => {
if (sceneCount == 0 && groupCount !== 0) {
return "subgroups";
}
return "scenes";
}, [sceneCount, groupCount]);
const { setTabKey } = useTabKey({
tabKey,
validTabs,
defaultTabKey: populatedDefaultTab,
baseURL: `/groups/${group.id}`,
});
return (
<Tabs
id="group-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<TabTitleCounter
messageID="scenes"
count={sceneCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
</Tab>
<Tab
eventKey="subgroups"
title={
<TabTitleCounter
messageID="sub_groups"
count={groupCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<GroupSubGroupsPanel active={tabKey === "subgroups"} group={group} />
</Tab>
</Tabs>
);
};
interface IProps { interface IProps {
group: GQL.GroupDataFragment; group: GQL.GroupDataFragment;
tabKey?: TabKey;
} }
interface IGroupParams { interface IGroupParams {
id: string; id: string;
tab?: string;
} }
const GroupPage: React.FC<IProps> = ({ group }) => { const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
@ -55,6 +128,7 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false; const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true; const showAllDetails = uiConfig?.showAllDetails ?? true;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const loadStickyHeader = useLoadStickyHeader(); const loadStickyHeader = useLoadStickyHeader();
@ -230,14 +304,6 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
} }
} }
const renderTabs = () => <GroupScenesPanel active={true} group={group} />;
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
}
}
if (updating || deleting) return <LoadingIndicator />; if (updating || deleting) return <LoadingIndicator />;
const headerClassName = cx("detail-header", { const headerClassName = cx("detail-header", {
@ -335,7 +401,15 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
<div className="detail-body"> <div className="detail-body">
<div className="group-body"> <div className="group-body">
<div className="group-tabs">{maybeRenderTab()}</div> <div className="group-tabs">
{!isEditing && (
<GroupTabs
group={group}
tabKey={tabKey}
abbreviateCounter={abbreviateCounter}
/>
)}
</div>
</div> </div>
</div> </div>
{renderDeleteAlert()} {renderDeleteAlert()}
@ -344,19 +418,33 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
}; };
const GroupLoader: React.FC<RouteComponentProps<IGroupParams>> = ({ const GroupLoader: React.FC<RouteComponentProps<IGroupParams>> = ({
location,
match, match,
}) => { }) => {
const { id } = match.params; const { id, tab } = match.params;
const { data, loading, error } = useFindGroup(id); const { data, loading, error } = useFindGroup(id);
useScrollToTopOnMount(); useScrollToTopOnMount();
if (tab && !isTabKey(tab)) {
return (
<Redirect
to={{
...location,
pathname: `/groups/${id}`,
}}
/>
);
}
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findGroup) if (!data?.findGroup)
return <ErrorMessage error={`No group found with id ${id}.`} />; return <ErrorMessage error={`No group found with id ${id}.`} />;
return <GroupPage group={data.findGroup} />; return (
<GroupPage group={data.findGroup} tabKey={tab as TabKey | undefined} />
);
}; };
export default GroupLoader; export default GroupLoader;

View file

@ -5,7 +5,28 @@ import TextUtils from "src/utils/text";
import { DetailItem } from "src/components/Shared/DetailItem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { DirectorLink } from "src/components/Shared/Link"; import { DirectorLink } from "src/components/Shared/Link";
import { TagLink } from "src/components/Shared/TagLink"; import { GroupLink, TagLink } from "src/components/Shared/TagLink";
interface IGroupDescription {
group: GQL.SlimGroupDataFragment;
description?: string | null;
}
const GroupsList: React.FC<{ groups: IGroupDescription[] }> = ({ groups }) => {
if (!groups.length) {
return null;
}
return (
<ul className="groups-list">
{groups.map((entry) => (
<li key={entry.group.id}>
<GroupLink group={entry.group} linkType="details" />
</li>
))}
</ul>
);
};
interface IGroupDetailsPanel { interface IGroupDetailsPanel {
group: GQL.GroupDataFragment; group: GQL.GroupDataFragment;
@ -48,6 +69,13 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({
value={renderTagsField()} value={renderTagsField()}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
{group.containing_groups.length > 0 && (
<DetailItem
id="containing_groups"
value={<GroupsList groups={group.containing_groups} />}
fullWidth={fullWidth}
/>
)}
</> </>
); );
} }

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
@ -26,6 +26,8 @@ import {
} from "src/utils/yup"; } from "src/utils/yup";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit"; import { useTagsEdit } from "src/hooks/tagsEdit";
import { Group } from "src/components/Groups/GroupSelect";
import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
interface IGroupEditPanel { interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>; group: Partial<GQL.GroupDataFragment>;
@ -60,6 +62,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
const [scrapedGroup, setScrapedGroup] = useState<GQL.ScrapedGroup>(); const [scrapedGroup, setScrapedGroup] = useState<GQL.ScrapedGroup>();
const [studio, setStudio] = useState<Studio | null>(null); const [studio, setStudio] = useState<Studio | null>(null);
const [containingGroups, setContainingGroups] = useState<Group[]>([]);
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
@ -68,6 +71,14 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
date: yupDateString(intl), date: yupDateString(intl),
studio_id: yup.string().required().nullable(), studio_id: yup.string().required().nullable(),
tag_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(),
containing_groups: yup
.array(
yup.object({
group_id: yup.string().required(),
description: yup.string().nullable().ensure(),
})
)
.defined(),
director: yup.string().ensure(), director: yup.string().ensure(),
urls: yupUniqueStringList(intl), urls: yupUniqueStringList(intl),
synopsis: yup.string().ensure(), synopsis: yup.string().ensure(),
@ -82,6 +93,9 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
date: group?.date ?? "", date: group?.date ?? "",
studio_id: group?.studio?.id ?? null, studio_id: group?.studio?.id ?? null,
tag_ids: (group?.tags ?? []).map((t) => t.id), tag_ids: (group?.tags ?? []).map((t) => t.id),
containing_groups: (group?.containing_groups ?? []).map((m) => {
return { group_id: m.group.id, description: m.description ?? "" };
}),
director: group?.director ?? "", director: group?.director ?? "",
urls: group?.urls ?? [], urls: group?.urls ?? [],
synopsis: group?.synopsis ?? "", synopsis: group?.synopsis ?? "",
@ -101,6 +115,17 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
(ids) => formik.setFieldValue("tag_ids", ids) (ids) => formik.setFieldValue("tag_ids", ids)
); );
const containingGroupEntries = useMemo(() => {
return formik.values.containing_groups
.map((m) => {
return {
group: containingGroups.find((mm) => mm.id === m.group_id),
description: m.description,
};
})
.filter((m) => m.group !== undefined) as IRelatedGroupEntry[];
}, [formik.values.containing_groups, containingGroups]);
function onSetStudio(item: Studio | null) { function onSetStudio(item: Studio | null) {
setStudio(item); setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null); formik.setFieldValue("studio_id", item ? item.id : null);
@ -110,6 +135,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
setStudio(group.studio ?? null); setStudio(group.studio ?? null);
}, [group.studio]); }, [group.studio]);
useEffect(() => {
setContainingGroups(group.containing_groups?.map((m) => m.group) ?? []);
}, [group.containing_groups]);
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
// Mousetrap.bind("u", (e) => { // Mousetrap.bind("u", (e) => {
@ -366,6 +395,30 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
return renderField("tag_ids", title, tagsControl()); return renderField("tag_ids", title, tagsControl());
} }
function onSetContainingGroupEntries(input: IRelatedGroupEntry[]) {
setContainingGroups(input.map((m) => m.group));
const newGroups = input.map((m) => ({
group_id: m.group.id,
description: m.description,
}));
formik.setFieldValue("containing_groups", newGroups);
}
function renderContainingGroupsField() {
const title = intl.formatMessage({ id: "containing_groups" });
const control = (
<RelatedGroupTable
value={containingGroupEntries}
onUpdate={onSetContainingGroupEntries}
excludeIDs={group.id ? [group.id] : undefined}
/>
);
return renderField("containing_groups", title, control);
}
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <div>
@ -394,6 +447,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
{renderInputField("aliases")} {renderInputField("aliases")}
{renderDurationField("duration")} {renderDurationField("duration")}
{renderDateField("date")} {renderDateField("date")}
{renderContainingGroupsField()}
{renderStudioField()} {renderStudioField()}
{renderInputField("director")} {renderInputField("director")}
{renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderURLListField("urls", onScrapeGroupURL, urlScrapable)}

View file

@ -1,6 +1,9 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { GroupsCriterion } from "src/models/list-filter/criteria/groups"; import {
GroupsCriterion,
GroupsCriterionOption,
} from "src/models/list-filter/criteria/groups";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList"; import { SceneList } from "src/components/Scenes/SceneList";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
@ -8,13 +11,14 @@ import { View } from "src/components/List/views";
interface IGroupScenesPanel { interface IGroupScenesPanel {
active: boolean; active: boolean;
group: GQL.GroupDataFragment; group: GQL.GroupDataFragment;
showSubGroupContent?: boolean;
} }
export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({ function useFilterHook(
active, group: Pick<GQL.GroupDataFragment, "id" | "name">,
group, showSubGroupContent?: boolean
}) => { ) {
function filterHook(filter: ListFilterModel) { return (filter: ListFilterModel) => {
const groupValue = { id: group.id, label: group.name }; const groupValue = { id: group.id, label: group.name };
// if group is already present, then we modify it, otherwise add // if group is already present, then we modify it, otherwise add
let groupCriterion = filter.criteria.find((c) => { let groupCriterion = filter.criteria.find((c) => {
@ -28,23 +32,35 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
) { ) {
// add the group if not present // add the group if not present
if ( if (
!groupCriterion.value.find((p) => { !groupCriterion.value.items.find((p) => {
return p.id === group.id; return p.id === group.id;
}) })
) { ) {
groupCriterion.value.push(groupValue); groupCriterion.value.items.push(groupValue);
} }
groupCriterion.modifier = GQL.CriterionModifier.IncludesAll; groupCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else { } else {
// overwrite // overwrite
groupCriterion = new GroupsCriterion(); groupCriterion = new GroupsCriterion(GroupsCriterionOption);
groupCriterion.value = [groupValue]; groupCriterion.value = {
items: [groupValue],
depth: showSubGroupContent ? -1 : 0,
excluded: [],
};
filter.criteria.push(groupCriterion); filter.criteria.push(groupCriterion);
} }
return filter; return filter;
} };
}
export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
active,
group,
showSubGroupContent,
}) => {
const filterHook = useFilterHook(group, showSubGroupContent);
if (group && group.id) { if (group && group.id) {
return ( return (
@ -53,6 +69,7 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
defaultSort="group_scene_number" defaultSort="group_scene_number"
alterQuery={active} alterQuery={active}
view={View.GroupScenes} view={View.GroupScenes}
fromGroupId={group.id}
/> />
); );
} }

View file

@ -0,0 +1,204 @@
import React, { useMemo } from "react";
import * as GQL from "src/core/generated-graphql";
import { GroupList } from "../GroupList";
import { ListFilterModel } from "src/models/list-filter/filter";
import {
ContainingGroupsCriterionOption,
GroupsCriterion,
} from "src/models/list-filter/criteria/groups";
import {
useRemoveSubGroups,
useReorderSubGroupsMutation,
} from "src/core/StashService";
import { ButtonToolbar } from "react-bootstrap";
import { ListOperationButtons } from "src/components/List/ListOperationButtons";
import { useListContext } from "src/components/List/ListProvider";
import {
PageSizeSelector,
SearchTermInput,
} from "src/components/List/ListFilter";
import { useFilter } from "src/components/List/FilterProvider";
import { IFilteredListToolbar } from "src/components/List/FilteredListToolbar";
import {
showWhenNoneSelected,
showWhenSelected,
} from "src/components/List/ItemList";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { useModal } from "src/hooks/modal";
import { AddSubGroupsDialog } from "./AddGroupsDialog";
const useContainingGroupFilterHook = (
group: Pick<GQL.StudioDataFragment, "id" | "name">,
showSubGroupContent?: boolean
) => {
return (filter: ListFilterModel) => {
const groupValue = { id: group.id, label: group.name };
// if studio is already present, then we modify it, otherwise add
let groupCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "containing_groups";
}) as GroupsCriterion | undefined;
if (groupCriterion) {
// add the group if not present
if (
!groupCriterion.value.items.find((p) => {
return p.id === group.id;
})
) {
groupCriterion.value.items.push(groupValue);
}
} else {
groupCriterion = new GroupsCriterion(ContainingGroupsCriterionOption);
groupCriterion.value = {
items: [groupValue],
excluded: [],
depth: showSubGroupContent ? -1 : 0,
};
groupCriterion.modifier = GQL.CriterionModifier.Includes;
filter.criteria.push(groupCriterion);
}
filter.sortBy = "sub_group_order";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
return filter;
};
};
const Toolbar: React.FC<IFilteredListToolbar> = ({
onEdit,
onDelete,
operations,
}) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { filter, setFilter } = useFilter();
return (
<ButtonToolbar className="filtered-list-toolbar">
<div>
<SearchTermInput filter={filter} onFilterUpdate={setFilter} />
</div>
<PageSizeSelector
pageSize={filter.itemsPerPage}
setPageSize={(size) => setFilter(filter.setPageSize(size))}
/>
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}
onDelete={onDelete}
/>
</ButtonToolbar>
);
};
interface IGroupSubGroupsPanel {
active: boolean;
group: GQL.GroupDataFragment;
}
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
active,
group,
}) => {
const intl = useIntl();
const Toast = useToast();
const { modal, showModal, closeModal } = useModal();
const [reorderSubGroups] = useReorderSubGroupsMutation();
const mutateRemoveSubGroups = useRemoveSubGroups();
const filterHook = useContainingGroupFilterHook(group);
const defaultFilter = useMemo(() => {
const sortBy = "sub_group_order";
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
defaultSortBy: sortBy,
});
// unset the sort by so that its not included in the URL
ret.sortBy = undefined;
return ret;
}, []);
async function removeSubGroups(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateRemoveSubGroups(group.id, Array.from(selectedIds.values()));
Toast.success(
intl.formatMessage(
{ id: "toast.removed_entity" },
{
count: selectedIds.size,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function onAddSubGroups() {
showModal(
<AddSubGroupsDialog containingGroup={group} onClose={closeModal} />
);
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.add_sub_groups" }),
onClick: onAddSubGroups,
isDisplayed: showWhenNoneSelected,
postRefetch: true,
icon: faPlus,
buttonVariant: "secondary",
},
{
text: intl.formatMessage({ id: "actions.remove_from_containing_group" }),
onClick: removeSubGroups,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faMinus,
buttonVariant: "danger",
},
];
function onMove(srcIds: string[], targetId: string, after: boolean) {
reorderSubGroups({
variables: {
input: {
group_id: group.id,
sub_group_ids: srcIds,
insert_at_id: targetId,
insert_after: after,
},
},
});
}
return (
<>
{modal}
<GroupList
defaultFilter={defaultFilter}
filterHook={filterHook}
alterQuery={active}
fromGroupId={group.id}
otherOperations={otherOperations}
onMove={onMove}
renderToolbar={(props) => <Toolbar {...props} />}
/>
</>
);
};

View file

@ -0,0 +1,137 @@
import React, { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { Form, Row, Col } from "react-bootstrap";
import { Group, GroupSelect } from "src/components/Groups/GroupSelect";
import cx from "classnames";
import { ListFilterModel } from "src/models/list-filter/filter";
export type GroupSceneIndexMap = Map<string, number | undefined>;
export interface IRelatedGroupEntry {
group: Group;
description?: GQL.InputMaybe<string> | undefined;
}
export const RelatedGroupTable: React.FC<{
value: IRelatedGroupEntry[];
onUpdate: (input: IRelatedGroupEntry[]) => void;
excludeIDs?: string[];
filterHook?: (f: ListFilterModel) => ListFilterModel;
disabled?: boolean;
}> = (props) => {
const { value, onUpdate } = props;
const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]);
const excludeIDs = useMemo(
() => [...groupIDs, ...(props.excludeIDs ?? [])],
[props.excludeIDs, groupIDs]
);
const updateFieldChanged = (index: number, description: string | null) => {
const newValues = value.map((existing, i) => {
if (i === index) {
return {
...existing,
description,
};
}
return existing;
});
onUpdate(newValues);
};
function onGroupSet(index: number, groups: Group[]) {
if (!groups.length) {
// remove this entry
const newValues = value.filter((_, i) => i !== index);
onUpdate(newValues);
return;
}
const group = groups[0];
const newValues = value.map((existing, i) => {
if (i === index) {
return {
...existing,
group: group,
};
}
return existing;
});
onUpdate(newValues);
}
function onNewGroupSet(groups: Group[]) {
if (!groups.length) {
return;
}
const group = groups[0];
const newValues = [
...value,
{
group: group,
scene_index: null,
},
];
onUpdate(newValues);
}
return (
<div className={cx("group-table", { "no-groups": !value.length })}>
<Row className="group-table-header">
<Col xs={9}></Col>
<Form.Label column xs={3} className="group-scene-number-header">
<FormattedMessage id="description" />
</Form.Label>
</Row>
{value.map((m, i) => (
<Row key={m.group.id} className="group-row">
<Col xs={9}>
<GroupSelect
onSelect={(items) => onGroupSet(i, items)}
values={[m.group!]}
excludeIds={excludeIDs}
filterHook={props.filterHook}
isDisabled={props.disabled}
/>
</Col>
<Col xs={3}>
<Form.Control
className="text-input"
value={m.description ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateFieldChanged(
i,
e.currentTarget.value === "" ? null : e.currentTarget.value
);
}}
disabled={props.disabled}
/>
</Col>
</Row>
))}
<Row className="group-row">
<Col xs={12}>
<GroupSelect
// re-create this component to refresh the default values updating the excluded ids
// setting the key to the length of the groupIDs array will cause the component to re-render
key={groupIDs.length}
onSelect={(items) => onNewGroupSet(items)}
values={[]}
excludeIds={excludeIDs}
filterHook={props.filterHook}
isDisabled={props.disabled}
/>
</Col>
</Row>
</div>
);
};

View file

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { PropsWithChildren, useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@ -17,6 +17,35 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { GroupCardGrid } from "./GroupCardGrid"; import { GroupCardGrid } from "./GroupCardGrid";
import { EditGroupsDialog } from "./EditGroupsDialog"; import { EditGroupsDialog } from "./EditGroupsDialog";
import { View } from "../List/views"; import { View } from "../List/views";
import {
IFilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
const GroupExportDialog: React.FC<{
open?: boolean;
selectedIds: Set<string>;
isExportAll?: boolean;
onClose: () => void;
}> = ({ open = false, selectedIds, isExportAll = false, onClose }) => {
if (!open) {
return null;
}
return (
<ExportDialog
exportInput={{
groups: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={onClose}
/>
);
};
const filterMode = GQL.FilterMode.Groups;
function getItems(result: GQL.FindGroupsQueryResult) { function getItems(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.groups ?? []; return result?.data?.findGroups?.groups ?? [];
@ -26,24 +55,57 @@ function getCount(result: GQL.FindGroupsQueryResult) {
return result?.data?.findGroups?.count ?? 0; return result?.data?.findGroups?.count ?? 0;
} }
interface IGroupList { interface IGroupListContext {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultFilter?: ListFilterModel;
view?: View; view?: View;
alterQuery?: boolean; alterQuery?: boolean;
selectable?: boolean;
}
export const GroupListContext: React.FC<
PropsWithChildren<IGroupListContext>
> = ({ alterQuery, filterHook, defaultFilter, view, selectable, children }) => {
return (
<ItemListContext
filterMode={filterMode}
defaultFilter={defaultFilter}
useResult={useFindGroups}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable={selectable}
>
{children}
</ItemListContext>
);
};
interface IGroupList extends IGroupListContext {
fromGroupId?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
} }
export const GroupList: React.FC<IGroupList> = ({ export const GroupList: React.FC<IGroupList> = ({
filterHook, filterHook,
alterQuery, alterQuery,
defaultFilter,
view, view,
fromGroupId,
onMove,
selectable,
renderToolbar,
otherOperations: providedOperations = [],
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Groups;
const otherOperations = [ const otherOperations = [
{ {
text: intl.formatMessage({ id: "actions.view_random" }), text: intl.formatMessage({ id: "actions.view_random" }),
@ -58,6 +120,7 @@ export const GroupList: React.FC<IGroupList> = ({
text: intl.formatMessage({ id: "actions.export_all" }), text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll, onClick: onExportAll,
}, },
...providedOperations,
]; ];
function addKeybinds( function addKeybinds(
@ -110,42 +173,23 @@ export const GroupList: React.FC<IGroupList> = ({
selectedIds: Set<string>, selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) { ) {
function maybeRenderGroupExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
groups: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
function renderGroups() {
if (!result.data?.findGroups) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<GroupCardGrid
groups={result.data.findGroups.groups}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
}
}
return ( return (
<> <>
{maybeRenderGroupExportDialog()} <GroupExportDialog
{renderGroups()} open={isExportDialogOpen}
selectedIds={selectedIds}
isExportAll={isExportAll}
onClose={() => setIsExportDialogOpen(false)}
/>
{filter.displayMode === DisplayMode.Grid && (
<GroupCardGrid
groups={result.data?.findGroups.groups ?? []}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
/>
)}
</> </>
); );
} }
@ -173,15 +217,12 @@ export const GroupList: React.FC<IGroupList> = ({
} }
return ( return (
<ItemListContext <GroupListContext
filterMode={filterMode}
useResult={useFindGroups}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery} alterQuery={alterQuery}
filterHook={filterHook} filterHook={filterHook}
view={view} view={view}
selectable defaultFilter={defaultFilter}
selectable={selectable}
> >
<ItemList <ItemList
view={view} view={view}
@ -190,7 +231,8 @@ export const GroupList: React.FC<IGroupList> = ({
renderContent={renderContent} renderContent={renderContent}
renderEditDialog={renderEditDialog} renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog} renderDeleteDialog={renderDeleteDialog}
renderToolbar={renderToolbar}
/> />
</ItemListContext> </GroupListContext>
); );
}; };

View file

@ -56,13 +56,14 @@ const groupSelectSort = PatchFunction(
sortGroupsByRelevance sortGroupsByRelevance
); );
const _GroupSelect: React.FC< export const GroupSelect: React.FC<
IFilterProps & IFilterProps &
IFilterValueProps<Group> & { IFilterValueProps<Group> & {
hoverPlacement?: Placement; hoverPlacement?: Placement;
excludeIds?: string[]; excludeIds?: string[];
filterHook?: (f: ListFilterModel) => ListFilterModel;
} }
> = (props) => { > = PatchComponent("GroupSelect", (props) => {
const [createGroup] = useGroupCreate(); const [createGroup] = useGroupCreate();
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
@ -75,12 +76,17 @@ const _GroupSelect: React.FC<
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
async function loadGroups(input: string): Promise<Option[]> { async function loadGroups(input: string): Promise<Option[]> {
const filter = new ListFilterModel(GQL.FilterMode.Groups); let filter = new ListFilterModel(GQL.FilterMode.Groups);
filter.searchTerm = input; filter.searchTerm = input;
filter.currentPage = 1; filter.currentPage = 1;
filter.itemsPerPage = maxOptionsShown; filter.itemsPerPage = maxOptionsShown;
filter.sortBy = "name"; filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc; filter.sortDirection = GQL.SortDirectionEnum.Asc;
if (props.filterHook) {
filter = props.filterHook(filter);
}
const query = await queryFindGroupsForSelect(filter); const query = await queryFindGroupsForSelect(filter);
let ret = query.data.findGroups.groups.filter((group) => { let ret = query.data.findGroups.groups.filter((group) => {
// HACK - we should probably exclude these in the backend query, but // HACK - we should probably exclude these in the backend query, but
@ -255,9 +261,7 @@ const _GroupSelect: React.FC<
closeMenuOnSelect={!props.isMulti} closeMenuOnSelect={!props.isMulti}
/> />
); );
}; });
export const GroupSelect = PatchComponent("GroupSelect", _GroupSelect);
const _GroupIDSelect: React.FC<IFilterProps & IFilterIDProps<Group>> = ( const _GroupIDSelect: React.FC<IFilterProps & IFilterIDProps<Group>> = (
props props

View file

@ -0,0 +1,28 @@
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { GroupLink } from "../Shared/TagLink";
export const GroupTag: React.FC<{
group: Pick<GQL.GroupDataFragment, "id" | "name" | "front_image_path">;
linkType?: "scene" | "sub_group" | "details";
description?: string;
}> = ({ group, linkType, description }) => {
return (
<div className="group-tag-container">
<Link to={`/groups/${group.id}`} className="group-tag col m-auto zoom-2">
<img
className="image-thumbnail"
alt={group.name ?? ""}
src={group.front_image_path ?? ""}
/>
</Link>
<GroupLink
group={group}
description={description}
linkType={linkType}
className="d-block"
/>
</div>
);
};

View file

@ -0,0 +1,110 @@
import {
faFilm,
faArrowUpLong,
faArrowDownLong,
} from "@fortawesome/free-solid-svg-icons";
import React, { useMemo } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { Count } from "../Shared/PopoverCountButton";
import { Icon } from "../Shared/Icon";
import { HoverPopover } from "../Shared/HoverPopover";
import { Link } from "react-router-dom";
import NavUtils from "src/utils/navigation";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
import { GroupTag } from "./GroupTag";
interface IProps {
group: Pick<
GQL.GroupDataFragment,
"id" | "name" | "containing_groups" | "sub_group_count"
>;
}
const ContainingGroupsCount: React.FC<IProps> = ({ group }) => {
const { containing_groups: containingGroups } = group;
const popoverContent = useMemo(() => {
if (!containingGroups.length) {
return [];
}
return containingGroups.map((entry) => (
<GroupTag
key={entry.group.id}
linkType="sub_group"
group={entry.group}
description={entry.description ?? undefined}
/>
));
}, [containingGroups]);
if (!containingGroups.length) {
return null;
}
return (
<HoverPopover
className="containing-group-count"
placement="bottom"
content={popoverContent}
>
<Link
to={NavUtils.makeContainingGroupsUrl(group)}
className="related-group-count"
>
<Count count={containingGroups.length} />
<Icon icon={faArrowUpLong} transform="shrink-4" />
</Link>
</HoverPopover>
);
};
const SubGroupCount: React.FC<IProps> = ({ group }) => {
const intl = useIntl();
const count = group.sub_group_count;
if (!count) {
return null;
}
function getTitle() {
const pluralCategory = intl.formatPlural(count);
const options = {
one: "sub_group",
other: "sub_groups",
};
const plural = intl.formatMessage({
id: options[pluralCategory as "one"] || options.other,
});
return `${count} ${plural}`;
}
return (
<OverlayTrigger
overlay={<Tooltip id={`sub-group-count-tooltip`}>{getTitle()}</Tooltip>}
placement="bottom"
>
<Link
to={NavUtils.makeSubGroupsUrl(group)}
className="related-group-count"
>
<Count count={count} />
<Icon icon={faArrowDownLong} transform="shrink-4" />
</Link>
</OverlayTrigger>
);
};
export const RelatedGroupPopoverButton: React.FC<IProps> = ({ group }) => {
return (
<span className="related-group-popover-button">
<Button className="minimal">
<Icon icon={faFilm} />
<ContainingGroupsCount group={group} />
<SubGroupCount group={group} />
</Button>
</span>
);
};

View file

@ -14,7 +14,8 @@
width: 100%; width: 100%;
} }
.group-scene-number { .group-scene-number,
.group-containing-group-description {
text-align: center; text-align: center;
} }
@ -89,3 +90,24 @@
} }
} }
} }
.groups-list {
list-style-type: none;
padding-inline-start: 0;
li {
display: inline;
}
}
.related-group-popover-button {
.containing-group-count {
display: inline-block;
}
.related-group-count .fa-icon {
color: $text-muted;
margin-left: 0;
margin-right: 0.25rem;
}
}

View file

@ -31,14 +31,16 @@ export interface IItemListOperation<T extends QueryResult> {
buttonVariant?: string; buttonVariant?: string;
} }
export const FilteredListToolbar: React.FC<{ export interface IFilteredListToolbar {
showEditFilter: (editingCriterion?: string) => void; showEditFilter?: (editingCriterion?: string) => void;
view?: View; view?: View;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
operations?: IListFilterOperation[]; operations?: IListFilterOperation[];
zoomable?: boolean; zoomable?: boolean;
}> = ({ }
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
showEditFilter, showEditFilter,
view, view,
onEdit, onEdit,
@ -60,13 +62,15 @@ export const FilteredListToolbar: React.FC<{
} }
return ( return (
<ButtonToolbar className="justify-content-center"> <ButtonToolbar className="filtered-list-toolbar">
<ListFilter {showEditFilter && (
onFilterUpdate={setFilter} <ListFilter
filter={filter} onFilterUpdate={setFilter}
openFilterDialog={() => showEditFilter()} filter={filter}
view={view} openFilterDialog={() => showEditFilter()}
/> view={view}
/>
)}
<ListOperationButtons <ListOperationButtons
onSelectAll={onSelectAll} onSelectAll={onSelectAll}
onSelectNone={onSelectNone} onSelectNone={onSelectNone}

View file

@ -22,7 +22,8 @@ export const HierarchicalLabelValueFilter: React.FC<
inputType !== "studios" && inputType !== "studios" &&
inputType !== "tags" && inputType !== "tags" &&
inputType !== "scene_tags" && inputType !== "scene_tags" &&
inputType !== "performer_tags" inputType !== "performer_tags" &&
inputType !== "groups"
) { ) {
return null; return null;
} }
@ -53,21 +54,30 @@ export const HierarchicalLabelValueFilter: React.FC<
if (inputType === "studios") { if (inputType === "studios") {
return "include-sub-studios"; return "include-sub-studios";
} }
if (inputType === "groups") {
return "include-sub-groups";
}
if (type === "children") { if (type === "children") {
return "include-parent-tags"; return "include-parent-tags";
} }
console.log(inputType);
return "include-sub-tags"; return "include-sub-tags";
} }
function criterionOptionTypeToIncludeUIString(): MessageDescriptor { function criterionOptionTypeToIncludeUIString(): MessageDescriptor {
const optionType = let id: string;
inputType === "studios" if (inputType === "studios") {
? "include_sub_studios" id = "include_sub_studios";
: type === "children" } else if (inputType === "groups") {
? "include_parent_tags" id = "include-sub-groups";
: "include_sub_tags"; } else if (type === "children") {
id = "include_parent_tags";
} else {
id = "include_sub_tags";
}
return { return {
id: optionType, id,
}; };
} }

View file

@ -30,7 +30,11 @@ import {
useListKeyboardShortcuts, useListKeyboardShortcuts,
useScrollToTopOnPageChange, useScrollToTopOnPageChange,
} from "./util"; } from "./util";
import { FilteredListToolbar, IItemListOperation } from "./FilteredListToolbar"; import {
FilteredListToolbar,
IFilteredListToolbar,
IItemListOperation,
} from "./FilteredListToolbar";
import { PagedList } from "./PagedList"; import { PagedList } from "./PagedList";
interface IItemListProps<T extends QueryResult, E extends IHasID> { interface IItemListProps<T extends QueryResult, E extends IHasID> {
@ -59,6 +63,7 @@ interface IItemListProps<T extends QueryResult, E extends IHasID> {
filter: ListFilterModel, filter: ListFilterModel,
selectedIds: Set<string> selectedIds: Set<string>
) => () => void; ) => () => void;
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
} }
export const ItemList = <T extends QueryResult, E extends IHasID>( export const ItemList = <T extends QueryResult, E extends IHasID>(
@ -73,6 +78,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
renderDeleteDialog, renderDeleteDialog,
renderMetadataByline, renderMetadataByline,
addKeybinds, addKeybinds,
renderToolbar: providedToolbar,
} = props; } = props;
const { filter, setFilter: updateFilter } = useFilter(); const { filter, setFilter: updateFilter } = useFilter();
@ -142,28 +148,30 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
} }
}, [addKeybinds, result, effectiveFilter, selectedIds]); }, [addKeybinds, result, effectiveFilter, selectedIds]);
async function onOperationClicked(o: IItemListOperation<T>) { const operations = useMemo(() => {
await o.onClick(result, effectiveFilter, selectedIds); async function onOperationClicked(o: IItemListOperation<T>) {
if (o.postRefetch) { await o.onClick(result, effectiveFilter, selectedIds);
result.refetch(); if (o.postRefetch) {
} result.refetch();
}
const operations = otherOperations?.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, effectiveFilter, selectedIds);
} }
}
return true; return otherOperations?.map((o) => ({
}, text: o.text,
icon: o.icon, onClick: () => {
buttonVariant: o.buttonVariant, onOperationClicked(o);
})); },
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, effectiveFilter, selectedIds);
}
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
}, [result, effectiveFilter, selectedIds, otherOperations]);
function onEdit() { function onEdit() {
if (!renderEditDialog) { if (!renderEditDialog) {
@ -215,16 +223,22 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
updateFilter(filter.clearCriteria()); updateFilter(filter.clearCriteria());
} }
const filterListToolbarProps = {
showEditFilter,
view: view,
operations: operations,
zoomable: zoomable,
onEdit: renderEditDialog ? onEdit : undefined,
onDelete: renderDeleteDialog ? onDelete : undefined,
};
return ( return (
<div className="item-list-container"> <div className="item-list-container">
<FilteredListToolbar {providedToolbar ? (
showEditFilter={showEditFilter} providedToolbar(filterListToolbarProps)
view={view} ) : (
operations={operations} <FilteredListToolbar {...filterListToolbarProps} />
zoomable={zoomable} )}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
<FilterTags <FilterTags
criteria={filter.criteria} criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
@ -258,6 +272,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
interface IItemListContextProps<T extends QueryResult, E extends IHasID> { interface IItemListContextProps<T extends QueryResult, E extends IHasID> {
filterMode: GQL.FilterMode; filterMode: GQL.FilterMode;
defaultSort?: string; defaultSort?: string;
defaultFilter?: ListFilterModel;
useResult: (filter: ListFilterModel) => T; useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number; getCount: (data: T) => number;
getItems: (data: T) => E[]; getItems: (data: T) => E[];
@ -275,6 +290,7 @@ export const ItemListContext = <T extends QueryResult, E extends IHasID>(
const { const {
filterMode, filterMode,
defaultSort, defaultSort,
defaultFilter: providedDefaultFilter,
useResult, useResult,
getCount, getCount,
getItems, getItems,
@ -287,10 +303,11 @@ export const ItemListContext = <T extends QueryResult, E extends IHasID>(
const emptyFilter = useMemo( const emptyFilter = useMemo(
() => () =>
providedDefaultFilter?.clone() ??
new ListFilterModel(filterMode, undefined, { new ListFilterModel(filterMode, undefined, {
defaultSortBy: defaultSort, defaultSortBy: defaultSort,
}), }),
[filterMode, defaultSort] [filterMode, defaultSort, providedDefaultFilter]
); );
const [filter, setFilterState] = useState<ListFilterModel>( const [filter, setFilterState] = useState<ListFilterModel>(
@ -343,3 +360,11 @@ export const showWhenSingleSelection = <T extends QueryResult>(
) => { ) => {
return selectedIds.size == 1; return selectedIds.size == 1;
}; };
export const showWhenNoneSelected = <T extends QueryResult>(
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size === 0;
};

View file

@ -1,5 +1,11 @@
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { SortDirectionEnum } from "src/core/generated-graphql"; import { SortDirectionEnum } from "src/core/generated-graphql";
import { import {
@ -102,36 +108,17 @@ export const SearchTermInput: React.FC<{
); );
}; };
interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel;
view?: View;
openFilterDialog: () => void;
}
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"];
export const ListFilter: React.FC<IListFilterProps> = ({ export const PageSizeSelector: React.FC<{
onFilterUpdate, pageSize: number;
filter, setPageSize: (pageSize: number) => void;
openFilterDialog, }> = ({ pageSize, setPageSize }) => {
view,
}) => {
const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false);
const perPageSelect = useRef(null);
const [perPageInput, perPageFocus] = useFocus();
const filterOptions = filter.options;
const intl = useIntl(); const intl = useIntl();
useEffect(() => { const perPageSelect = useRef(null);
Mousetrap.bind("r", () => onReshuffleRandomSort()); const [perPageInput, perPageFocus] = useFocus();
const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false);
return () => {
Mousetrap.unbind("r");
};
});
useEffect(() => { useEffect(() => {
if (customPageSizeShowing) { if (customPageSizeShowing) {
@ -139,6 +126,27 @@ export const ListFilter: React.FC<IListFilterProps> = ({
} }
}, [customPageSizeShowing, perPageFocus]); }, [customPageSizeShowing, perPageFocus]);
const pageSizeOptions = useMemo(() => {
const ret = PAGE_SIZE_OPTIONS.map((o) => {
return {
label: o,
value: o,
};
});
const currentPerPage = pageSize.toString();
if (!ret.find((o) => o.value === currentPerPage)) {
ret.push({ label: currentPerPage, value: currentPerPage });
ret.sort((a, b) => parseInt(a.value, 10) - parseInt(b.value, 10));
}
ret.push({
label: `${intl.formatMessage({ id: "custom" })}...`,
value: "custom",
});
return ret;
}, [intl, pageSize]);
function onChangePageSize(val: string) { function onChangePageSize(val: string) {
if (val === "custom") { if (val === "custom") {
// added timeout since Firefox seems to trigger the rootClose immediately // added timeout since Firefox seems to trigger the rootClose immediately
@ -154,6 +162,94 @@ export const ListFilter: React.FC<IListFilterProps> = ({
return; return;
} }
setPageSize(pp);
}
return (
<div className="page-size-selector">
<Form.Control
as="select"
ref={perPageSelect}
onChange={(e) => onChangePageSize(e.target.value)}
value={pageSize.toString()}
className="btn-secondary"
>
{pageSizeOptions.map((s) => (
<option value={s.value} key={s.value}>
{s.label}
</option>
))}
</Form.Control>
<Overlay
target={perPageSelect.current}
show={customPageSizeShowing}
placement="bottom"
rootClose
onHide={() => setCustomPageSizeShowing(false)}
>
<Popover id="custom_pagesize_popover">
<Form inline>
<InputGroup>
<Form.Control
type="number"
min={1}
className="text-input"
ref={perPageInput}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
onChangePageSize(
(perPageInput.current as HTMLInputElement)?.value ?? ""
);
e.preventDefault();
}
}}
/>
<InputGroup.Append>
<Button
variant="primary"
onClick={() =>
onChangePageSize(
(perPageInput.current as HTMLInputElement)?.value ?? ""
)
}
>
<Icon icon={faCheck} />
</Button>
</InputGroup.Append>
</InputGroup>
</Form>
</Popover>
</Overlay>
</div>
);
};
interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel;
view?: View;
openFilterDialog: () => void;
}
export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate,
filter,
openFilterDialog,
view,
}) => {
const filterOptions = filter.options;
const intl = useIntl();
useEffect(() => {
Mousetrap.bind("r", () => onReshuffleRandomSort());
return () => {
Mousetrap.unbind("r");
};
});
function onChangePageSize(pp: number) {
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
newFilter.itemsPerPage = pp; newFilter.itemsPerPage = pp;
newFilter.currentPage = 1; newFilter.currentPage = 1;
@ -211,25 +307,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
(o) => o.value === filter.sortBy (o) => o.value === filter.sortBy
); );
const pageSizeOptions = PAGE_SIZE_OPTIONS.map((o) => {
return {
label: o,
value: o,
};
});
const currentPerPage = filter.itemsPerPage.toString();
if (!pageSizeOptions.find((o) => o.value === currentPerPage)) {
pageSizeOptions.push({ label: currentPerPage, value: currentPerPage });
pageSizeOptions.sort(
(a, b) => parseInt(a.value, 10) - parseInt(b.value, 10)
);
}
pageSizeOptions.push({
label: `${intl.formatMessage({ id: "custom" })}...`,
value: "custom",
});
return ( return (
<> <>
<div className="mb-2 d-flex"> <div className="mb-2 d-flex">
@ -301,63 +378,10 @@ export const ListFilter: React.FC<IListFilterProps> = ({
)} )}
</Dropdown> </Dropdown>
<div className="mb-2"> <PageSizeSelector
<Form.Control pageSize={filter.itemsPerPage}
as="select" setPageSize={onChangePageSize}
ref={perPageSelect} />
onChange={(e) => onChangePageSize(e.target.value)}
value={filter.itemsPerPage.toString()}
className="btn-secondary"
>
{pageSizeOptions.map((s) => (
<option value={s.value} key={s.value}>
{s.label}
</option>
))}
</Form.Control>
<Overlay
target={perPageSelect.current}
show={customPageSizeShowing}
placement="bottom"
rootClose
onHide={() => setCustomPageSizeShowing(false)}
>
<Popover id="custom_pagesize_popover">
<Form inline>
<InputGroup>
<Form.Control
type="number"
min={1}
className="text-input"
ref={perPageInput}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
onChangePageSize(
(perPageInput.current as HTMLInputElement)?.value ??
""
);
e.preventDefault();
}
}}
/>
<InputGroup.Append>
<Button
variant="primary"
onClick={() =>
onChangePageSize(
(perPageInput.current as HTMLInputElement)?.value ??
""
)
}
>
<Icon icon={faCheck} />
</Button>
</InputGroup.Append>
</InputGroup>
</Form>
</Popover>
</Overlay>
</div>
</> </>
); );
} }

View file

@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { PropsWithChildren, useEffect } from "react";
import { import {
Button, Button,
ButtonGroup, ButtonGroup,
@ -16,6 +16,23 @@ import {
faTrash, faTrash,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
children,
}) => {
if (!children) return null;
return (
<Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon={faEllipsisH} />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{children}
</Dropdown.Menu>
</Dropdown>
);
};
export interface IListFilterOperation { export interface IListFilterOperation {
text: string; text: string;
onClick: () => void; onClick: () => void;
@ -154,6 +171,11 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
if (otherOperations) { if (otherOperations) {
otherOperations otherOperations
.filter((o) => { .filter((o) => {
// buttons with icons are rendered in the button group
if (o.icon) {
return false;
}
if (!o.isDisplayed) { if (!o.isDisplayed) {
return true; return true;
} }
@ -173,18 +195,11 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
}); });
} }
if (options.length > 0) { return (
return ( <OperationDropdown>
<Dropdown> {options.length > 0 ? options : undefined}
<Dropdown.Toggle variant="secondary" id="more-menu"> </OperationDropdown>
<Icon icon={faEllipsisH} /> );
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{options}
</Dropdown.Menu>
</Dropdown>
);
}
} }
return ( return (

View file

@ -66,6 +66,26 @@ export function useListContext<T extends IHasID = IHasID>() {
return context as IListContextState<T>; return context as IListContextState<T>;
} }
const emptyState: IListContextState = {
selectable: false,
selectedIds: new Set(),
getSelected: () => [],
onSelectChange: () => {},
onSelectAll: () => {},
onSelectNone: () => {},
items: [],
};
export function useListContextOptional<T extends IHasID = IHasID>() {
const context = React.useContext(ListStateContext);
if (context === null) {
return emptyState as IListContextState<T>;
}
return context as IListContextState<T>;
}
interface IQueryResultContextOptions< interface IQueryResultContextOptions<
T extends QueryResult, T extends QueryResult,
E extends IHasID = IHasID E extends IHasID = IHasID

View file

@ -572,6 +572,10 @@ input[type="range"].zoom-slider {
} }
} }
.filtered-list-toolbar {
justify-content: center;
}
.search-term-input { .search-term-input {
margin-right: 0.5rem; margin-right: 0.5rem;
} }

View file

@ -7,6 +7,7 @@ import { QueryResult } from "@apollo/client";
import { IHasID } from "src/utils/data"; import { IHasID } from "src/utils/data";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { View } from "./views"; import { View } from "./views";
import { usePrevious } from "src/hooks/state";
export function useFilterURL( export function useFilterURL(
filter: ListFilterModel, filter: ListFilterModel,
@ -180,6 +181,25 @@ export function useListSelect<T extends { id: string }>(items: T[]) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>(); const [lastClickedId, setLastClickedId] = useState<string>();
const prevItems = usePrevious(items);
useEffect(() => {
if (prevItems === items) {
return;
}
// filter out any selectedIds that are no longer in the list
const newSelectedIds = new Set<string>();
selectedIds.forEach((id) => {
if (items.some((item) => item.id === id)) {
newSelectedIds.add(id);
}
});
setSelectedIds(newSelectedIds);
}, [prevItems, items, selectedIds]);
function singleSelect(id: string, selected: boolean) { function singleSelect(id: string, selected: boolean) {
setLastClickedId(id); setLastClickedId(id);

View file

@ -31,4 +31,5 @@ export enum View {
StudioChildren = "studio_children", StudioChildren = "studio_children",
GroupScenes = "group_scenes", GroupScenes = "group_scenes",
GroupSubGroups = "group_sub_groups",
} }

View file

@ -1,15 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { import { GalleryLink, TagLink, SceneMarkerLink } from "../Shared/TagLink";
GalleryLink,
TagLink,
GroupLink,
SceneMarkerLink,
} from "../Shared/TagLink";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { SweatDrops } from "../Shared/SweatDrops"; import { SweatDrops } from "../Shared/SweatDrops";
import { TruncatedText } from "../Shared/TruncatedText"; import { TruncatedText } from "../Shared/TruncatedText";
@ -20,7 +15,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
import { FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import { import {
faBox, faBox,
faCopy, faCopy,
@ -34,6 +29,7 @@ import { PreviewScrubber } from "./PreviewScrubber";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
import { GroupTag } from "../Groups/GroupTag";
interface IScenePreviewProps { interface IScenePreviewProps {
isPortrait: boolean; isPortrait: boolean;
@ -106,8 +102,26 @@ interface ISceneCardProps {
selected?: boolean | undefined; selected?: boolean | undefined;
zoomIndex?: number; zoomIndex?: number;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
} }
const Description: React.FC<{
sceneNumber?: number;
}> = ({ sceneNumber }) => {
if (!sceneNumber) return null;
return (
<>
<hr />
{sceneNumber !== undefined && (
<span className="scene-group-scene-number">
<FormattedMessage id="scene" /> #{sceneNumber}
</span>
)}
</>
);
};
const SceneCardPopovers = PatchComponent( const SceneCardPopovers = PatchComponent(
"SceneCard.Popovers", "SceneCard.Popovers",
(props: ISceneCardProps) => { (props: ISceneCardProps) => {
@ -116,6 +130,17 @@ const SceneCardPopovers = PatchComponent(
[props.scene] [props.scene]
); );
const sceneNumber = useMemo(() => {
if (!props.fromGroupId) {
return undefined;
}
const group = props.scene.groups.find(
(g) => g.group.id === props.fromGroupId
);
return group?.scene_index ?? undefined;
}, [props.fromGroupId, props.scene.groups]);
function maybeRenderTagPopoverButton() { function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) return; if (props.scene.tags.length <= 0) return;
@ -147,23 +172,7 @@ const SceneCardPopovers = PatchComponent(
if (props.scene.groups.length <= 0) return; if (props.scene.groups.length <= 0) return;
const popoverContent = props.scene.groups.map((sceneGroup) => ( const popoverContent = props.scene.groups.map((sceneGroup) => (
<div className="group-tag-container row" key={sceneGroup.group.id}> <GroupTag key={sceneGroup.group.id} group={sceneGroup.group} />
<Link
to={`/groups/${sceneGroup.group.id}`}
className="group-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={sceneGroup.group.name ?? ""}
src={sceneGroup.group.front_image_path ?? ""}
/>
</Link>
<GroupLink
key={sceneGroup.group.id}
group={sceneGroup.group}
className="d-block"
/>
</div>
)); ));
return ( return (
@ -283,10 +292,12 @@ const SceneCardPopovers = PatchComponent(
props.scene.scene_markers.length > 0 || props.scene.scene_markers.length > 0 ||
props.scene?.o_counter || props.scene?.o_counter ||
props.scene.galleries.length > 0 || props.scene.galleries.length > 0 ||
props.scene.organized) props.scene.organized ||
sceneNumber !== undefined)
) { ) {
return ( return (
<> <>
<Description sceneNumber={sceneNumber} />
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}

View file

@ -10,6 +10,7 @@ interface ISceneCardsGrid {
selectedIds: Set<string>; selectedIds: Set<string>;
zoomIndex: number; zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
} }
export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({ export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
@ -18,6 +19,7 @@ export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
selectedIds, selectedIds,
zoomIndex, zoomIndex,
onSelectChange, onSelectChange,
fromGroupId,
}) => { }) => {
const [componentRef, { width }] = useContainerDimensions(); const [componentRef, { width }] = useContainerDimensions();
return ( return (
@ -35,6 +37,7 @@ export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(scene.id, selected, shiftKey) onSelectChange(scene.id, selected, shiftKey)
} }
fromGroupId={fromGroupId}
/> />
))} ))}
</div> </div>

View file

@ -13,7 +13,7 @@ export const SceneGroupPanel: React.FC<ISceneGroupPanelProps> = (
<GroupCard <GroupCard
key={sceneGroup.group.id} key={sceneGroup.group.id}
group={sceneGroup.group} group={sceneGroup.group}
sceneIndex={sceneGroup.scene_index ?? undefined} sceneNumber={sceneGroup.scene_index ?? undefined}
/> />
)); ));

View file

@ -75,6 +75,7 @@ interface ISceneList {
defaultSort?: string; defaultSort?: string;
view?: View; view?: View;
alterQuery?: boolean; alterQuery?: boolean;
fromGroupId?: string;
} }
export const SceneList: React.FC<ISceneList> = ({ export const SceneList: React.FC<ISceneList> = ({
@ -82,6 +83,7 @@ export const SceneList: React.FC<ISceneList> = ({
defaultSort, defaultSort,
view, view,
alterQuery, alterQuery,
fromGroupId,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
@ -297,6 +299,7 @@ export const SceneList: React.FC<ISceneList> = ({
zoomIndex={filter.zoomIndex} zoomIndex={filter.zoomIndex}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectChange={onSelectChange} onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
/> />
); );
} }

View file

@ -208,6 +208,10 @@ textarea.scene-description {
&-preview { &-preview {
aspect-ratio: 16/9; aspect-ratio: 16/9;
} }
.scene-group-scene-number {
text-align: center;
}
} }
.scene-card, .scene-card,

View file

@ -1,10 +1,18 @@
import React, { MutableRefObject, useRef, useState } from "react"; import React, {
MutableRefObject,
PropsWithChildren,
useRef,
useState,
} from "react";
import { Card, Form } from "react-bootstrap"; import { Card, Form } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
import { TruncatedText } from "../TruncatedText"; import { TruncatedText } from "../TruncatedText";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
import { Icon } from "../Icon";
import { faGripLines } from "@fortawesome/free-solid-svg-icons";
import { DragSide, useDragMoveSelect } from "./dragMoveSelect";
interface ICardProps { interface ICardProps {
className?: string; className?: string;
@ -24,6 +32,10 @@ interface ICardProps {
resumeTime?: number; resumeTime?: number;
duration?: number; duration?: number;
interactiveHeatmap?: string; interactiveHeatmap?: string;
// move logic - both of the following are required to enable move dragging
objectId?: string; // required for move dragging
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
} }
export const calculateCardWidth = ( export const calculateCardWidth = (
@ -66,7 +78,72 @@ export const useContainerDimensions = <T extends HTMLElement = HTMLDivElement>(
return [target, dimension]; return [target, dimension];
}; };
const Checkbox: React.FC<{
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}> = ({ selected = false, onSelectedChanged }) => {
let shiftKey = false;
return (
<Form.Control
type="checkbox"
// #2750 - add mousetrap class to ensure keyboard shortcuts work
className="card-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged!(!selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
);
};
const DragHandle: React.FC<{
setInHandle: (inHandle: boolean) => void;
}> = ({ setInHandle }) => {
function onMouseEnter() {
setInHandle(true);
}
function onMouseLeave() {
setInHandle(false);
}
return (
<span onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Icon className="card-drag-handle" icon={faGripLines} />
</span>
);
};
const Controls: React.FC<PropsWithChildren<{}>> = ({ children }) => {
return <div className="card-controls">{children}</div>;
};
const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => {
if (dragSide === undefined) {
return null;
}
return (
<div
className={`move-target move-target-${
dragSide === DragSide.BEFORE ? "before" : "after"
}`}
></div>
);
};
export const GridCard: React.FC<ICardProps> = (props: ICardProps) => { export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({
selecting: props.selecting || false,
selected: props.selected || false,
onSelectedChanged: props.onSelectedChanged,
objectId: props.objectId,
onMove: props.onMove,
});
function handleImageClick(event: React.MouseEvent<HTMLElement, MouseEvent>) { function handleImageClick(event: React.MouseEvent<HTMLElement, MouseEvent>) {
const { shiftKey } = event; const { shiftKey } = event;
@ -80,49 +157,6 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
} }
} }
function handleDrag(event: React.DragEvent<HTMLElement>) {
if (props.selecting) {
event.dataTransfer.setData("text/plain", "");
event.dataTransfer.setDragImage(new Image(), 0, 0);
}
}
function handleDragOver(event: React.DragEvent<HTMLElement>) {
const ev = event;
const shiftKey = false;
if (!props.onSelectedChanged) {
return;
}
if (props.selecting && !props.selected) {
props.onSelectedChanged(true, shiftKey);
}
ev.dataTransfer.dropEffect = "move";
ev.preventDefault();
}
let shiftKey = false;
function maybeRenderCheckbox() {
if (props.onSelectedChanged) {
return (
<Form.Control
type="checkbox"
// #2750 - add mousetrap class to ensure keyboard shortcuts work
className="card-check mousetrap"
checked={props.selected}
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
);
}
}
function maybeRenderInteractiveHeatmap() { function maybeRenderInteractiveHeatmap() {
if (props.interactiveHeatmap) { if (props.interactiveHeatmap) {
return ( return (
@ -156,16 +190,26 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
<Card <Card
className={cx(props.className, "grid-card")} className={cx(props.className, "grid-card")}
onClick={handleImageClick} onClick={handleImageClick}
onDragStart={handleDrag} {...dragProps}
onDragOver={handleDragOver}
draggable={props.onSelectedChanged && props.selecting}
style={ style={
props.width && !ScreenUtils.isMobile() props.width && !ScreenUtils.isMobile()
? { width: `${props.width}px` } ? { width: `${props.width}px` }
: {} : {}
} }
> >
{maybeRenderCheckbox()} {moveTarget !== undefined && <MoveTarget dragSide={moveTarget} />}
<Controls>
{props.onSelectedChanged && (
<Checkbox
selected={props.selected}
onSelectedChanged={props.onSelectedChanged}
/>
)}
{!!props.objectId && props.onMove && (
<DragHandle setInHandle={setInHandle} />
)}
</Controls>
<div className={cx(props.thumbnailSectionClassName, "thumbnail-section")}> <div className={cx(props.thumbnailSectionClassName, "thumbnail-section")}>
<Link <Link

View file

@ -0,0 +1,143 @@
import { useState } from "react";
import { useListContextOptional } from "src/components/List/ListProvider";
export enum DragSide {
BEFORE,
AFTER,
}
export function useDragMoveSelect(props: {
selecting: boolean;
selected: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
objectId?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
}) {
const { selectedIds } = useListContextOptional();
const [inHandle, setInHandle] = useState(false);
const [moveSrc, setMoveSrc] = useState(false);
const [moveTarget, setMoveTarget] = useState<DragSide | undefined>();
const canSelect = props.onSelectedChanged && props.selecting;
const canMove = !!props.objectId && props.onMove && inHandle;
const draggable = canSelect || canMove;
function onDragStart(event: React.DragEvent<HTMLElement>) {
if (!draggable) {
event.preventDefault();
return;
}
if (!inHandle && props.selecting) {
event.dataTransfer.setData("text/plain", "");
// event.dataTransfer.setDragImage(new Image(), 0, 0);
event.dataTransfer.effectAllowed = "copy";
event.stopPropagation();
} else if (inHandle && props.objectId) {
if (selectedIds.size > 1 && selectedIds.has(props.objectId)) {
// moving all selected
const movingIds = Array.from(selectedIds.values()).join(",");
event.dataTransfer.setData("text/plain", movingIds);
} else {
// moving single
setMoveSrc(true);
event.dataTransfer.setData("text/plain", props.objectId);
}
event.dataTransfer.effectAllowed = "move";
event.stopPropagation();
}
}
function doSetMoveTarget(event: React.DragEvent<HTMLElement>) {
const isBefore =
event.nativeEvent.offsetX < event.currentTarget.clientWidth / 2;
if (isBefore && moveTarget !== DragSide.BEFORE) {
setMoveTarget(DragSide.BEFORE);
} else if (!isBefore && moveTarget !== DragSide.AFTER) {
setMoveTarget(DragSide.AFTER);
}
}
function onDragEnter(event: React.DragEvent<HTMLElement>) {
const ev = event;
const shiftKey = false;
if (ev.dataTransfer.effectAllowed === "copy") {
if (!props.onSelectedChanged) {
return;
}
if (props.selecting && !props.selected) {
props.onSelectedChanged(true, shiftKey);
}
ev.dataTransfer.dropEffect = "copy";
ev.preventDefault();
} else if (ev.dataTransfer.effectAllowed === "move" && !moveSrc) {
doSetMoveTarget(event);
ev.dataTransfer.dropEffect = "move";
ev.preventDefault();
} else {
ev.dataTransfer.dropEffect = "none";
}
}
function onDragLeave(event: React.DragEvent<HTMLElement>) {
if (event.currentTarget.contains(event.relatedTarget as Node)) {
return;
}
setMoveTarget(undefined);
}
function onDragOver(event: React.DragEvent<HTMLElement>) {
if (event.dataTransfer.effectAllowed === "move" && moveSrc) {
return;
}
doSetMoveTarget(event);
event.preventDefault();
}
function onDragEnd() {
setMoveTarget(undefined);
setMoveSrc(false);
}
function onDrop(event: React.DragEvent<HTMLElement>) {
const ev = event;
if (
ev.dataTransfer.effectAllowed === "copy" ||
!props.onMove ||
!props.objectId
) {
return;
}
const srcIds = ev.dataTransfer.getData("text/plain").split(",");
const targetId = props.objectId;
const after = moveTarget === DragSide.AFTER;
props.onMove(srcIds, targetId, after);
onDragEnd();
}
return {
inHandle,
setInHandle,
moveTarget,
dragProps: {
draggable: draggable || undefined,
onDragStart,
onDragEnter,
onDragLeave,
onDragOver,
onDragEnd,
onDrop,
},
};
}

View file

@ -57,3 +57,28 @@
transition: opacity 0.5s; transition: opacity 0.5s;
} }
} }
.move-target {
align-items: center;
background-color: $primary;
color: $secondary;
display: flex;
height: 100%;
justify-content: center;
opacity: 0.5;
pointer-events: none;
position: absolute;
width: 10%;
&.move-target-before {
left: 0;
}
&.move-target-after {
right: 0;
}
}
.card-drag-handle {
filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.7));
}

View file

@ -1,23 +1,16 @@
import React from "react"; import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import {
import { IconDefinition, SizeProp } from "@fortawesome/fontawesome-svg-core"; FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
interface IIcon { export const Icon: React.FC<FontAwesomeIconProps> = PatchComponent(
icon: IconDefinition;
className?: string;
color?: string;
size?: SizeProp;
}
export const Icon: React.FC<IIcon> = PatchComponent(
"Icon", "Icon",
({ icon, className, color, size }) => ( (props) => (
<FontAwesomeIcon <FontAwesomeIcon
icon={icon} {...props}
className={`fa-icon ${className ?? ""}`} className={`fa-icon ${props.className ?? ""}`}
color={color}
size={size}
/> />
) )
); );

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { IntlShape, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
@ -52,70 +52,96 @@ const Select: React.FC<IMultiSetProps> = (props) => {
); );
}; };
export const MultiSet: React.FC<IMultiSetProps> = (props) => { function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) {
const intl = useIntl(); switch (mode) {
const modes = [ case GQL.BulkUpdateIdMode.Set:
GQL.BulkUpdateIdMode.Set, return intl.formatMessage({
GQL.BulkUpdateIdMode.Add, id: "actions.overwrite",
GQL.BulkUpdateIdMode.Remove, defaultMessage: "Overwrite",
]; });
case GQL.BulkUpdateIdMode.Add:
function getModeText(mode: GQL.BulkUpdateIdMode) { return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" });
switch (mode) { case GQL.BulkUpdateIdMode.Remove:
case GQL.BulkUpdateIdMode.Set: return intl.formatMessage({
return intl.formatMessage({ id: "actions.remove",
id: "actions.overwrite", defaultMessage: "Remove",
defaultMessage: "Overwrite", });
});
case GQL.BulkUpdateIdMode.Add:
return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" });
case GQL.BulkUpdateIdMode.Remove:
return intl.formatMessage({
id: "actions.remove",
defaultMessage: "Remove",
});
}
} }
}
function onSetMode(mode: GQL.BulkUpdateIdMode) { export const MultiSetModeButton: React.FC<{
if (mode === props.mode) { mode: GQL.BulkUpdateIdMode;
active: boolean;
onClick: () => void;
disabled?: boolean;
}> = ({ mode, active, onClick, disabled }) => {
const intl = useIntl();
return (
<Button
key={mode}
variant="primary"
active={active}
size="sm"
onClick={onClick}
disabled={disabled}
>
{getModeText(intl, mode)}
</Button>
);
};
const modes = [
GQL.BulkUpdateIdMode.Set,
GQL.BulkUpdateIdMode.Add,
GQL.BulkUpdateIdMode.Remove,
];
export const MultiSetModeButtons: React.FC<{
mode: GQL.BulkUpdateIdMode;
onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
disabled?: boolean;
}> = ({ mode, onSetMode, disabled }) => {
return (
<ButtonGroup className="button-group-above">
{modes.map((m) => (
<MultiSetModeButton
key={m}
mode={m}
active={mode === m}
onClick={() => onSetMode(m)}
disabled={disabled}
/>
))}
</ButtonGroup>
);
};
export const MultiSet: React.FC<IMultiSetProps> = (props) => {
const { mode, onUpdate, existingIds } = props;
function onSetMode(m: GQL.BulkUpdateIdMode) {
if (m === mode) {
return; return;
} }
// if going to Set, set the existing ids // if going to Set, set the existing ids
if (mode === GQL.BulkUpdateIdMode.Set && props.existingIds) { if (m === GQL.BulkUpdateIdMode.Set && existingIds) {
props.onUpdate(props.existingIds); onUpdate(existingIds);
// if going from Set, wipe the ids // if going from Set, wipe the ids
} else if ( } else if (
mode !== GQL.BulkUpdateIdMode.Set && m !== GQL.BulkUpdateIdMode.Set &&
props.mode === GQL.BulkUpdateIdMode.Set mode === GQL.BulkUpdateIdMode.Set
) { ) {
props.onUpdate([]); onUpdate([]);
} }
props.onSetMode(mode); props.onSetMode(m);
}
function renderModeButton(mode: GQL.BulkUpdateIdMode) {
return (
<Button
key={mode}
variant="primary"
active={props.mode === mode}
size="sm"
onClick={() => onSetMode(mode)}
disabled={props.disabled}
>
{getModeText(mode)}
</Button>
);
} }
return ( return (
<div className="multi-set"> <div className="multi-set">
<ButtonGroup className="button-group-above"> <MultiSetModeButtons mode={mode} onSetMode={onSetMode} />
{modes.map((m) => renderModeButton(m))}
</ButtonGroup>
<Select {...props} /> <Select {...props} />
</div> </div>
); );

View file

@ -7,7 +7,7 @@ import {
faVideo, faVideo,
faMapMarkerAlt, faMapMarkerAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { useMemo } from "react"; import React from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { FormattedNumber, useIntl } from "react-intl"; import { FormattedNumber, useIntl } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -15,12 +15,36 @@ import { ConfigurationContext } from "src/hooks/Config";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
export const Count: React.FC<{
count: number;
}> = ({ count }) => {
const { configuration } = React.useContext(ConfigurationContext);
const abbreviateCounter = configuration?.ui.abbreviateCounters ?? false;
if (!abbreviateCounter) {
return <span>{count}</span>;
}
const formatted = TextUtils.abbreviateCounter(count);
return (
<span>
<FormattedNumber
value={formatted.size}
maximumFractionDigits={formatted.digits}
/>
{formatted.unit}
</span>
);
};
type PopoverLinkType = type PopoverLinkType =
| "scene" | "scene"
| "image" | "image"
| "gallery" | "gallery"
| "marker" | "marker"
| "group" | "group"
| "sub_group"
| "performer" | "performer"
| "studio"; | "studio";
@ -37,11 +61,9 @@ export const PopoverCountButton: React.FC<IProps> = ({
type, type,
count, count,
}) => { }) => {
const { configuration } = React.useContext(ConfigurationContext);
const abbreviateCounter = configuration?.ui.abbreviateCounters ?? false;
const intl = useIntl(); const intl = useIntl();
// TODO - refactor - create SceneIcon, ImageIcon etc components
function getIcon() { function getIcon() {
switch (type) { switch (type) {
case "scene": case "scene":
@ -53,6 +75,7 @@ export const PopoverCountButton: React.FC<IProps> = ({
case "marker": case "marker":
return faMapMarkerAlt; return faMapMarkerAlt;
case "group": case "group":
case "sub_group":
return faFilm; return faFilm;
case "performer": case "performer":
return faUser; return faUser;
@ -88,6 +111,11 @@ export const PopoverCountButton: React.FC<IProps> = ({
one: "group", one: "group",
other: "groups", other: "groups",
}; };
case "sub_group":
return {
one: "sub_group",
other: "sub_groups",
};
case "performer": case "performer":
return { return {
one: "performer", one: "performer",
@ -104,27 +132,12 @@ export const PopoverCountButton: React.FC<IProps> = ({
function getTitle() { function getTitle() {
const pluralCategory = intl.formatPlural(count); const pluralCategory = intl.formatPlural(count);
const options = getPluralOptions(); const options = getPluralOptions();
const plural = options[pluralCategory as "one"] || options.other; const plural = intl.formatMessage({
id: options[pluralCategory as "one"] || options.other,
});
return `${count} ${plural}`; return `${count} ${plural}`;
} }
const countEl = useMemo(() => {
if (!abbreviateCounter) {
return count;
}
const formatted = TextUtils.abbreviateCounter(count);
return (
<span>
<FormattedNumber
value={formatted.size}
maximumFractionDigits={formatted.digits}
/>
{formatted.unit}
</span>
);
}, [count, abbreviateCounter]);
return ( return (
<> <>
<OverlayTrigger <OverlayTrigger
@ -134,7 +147,7 @@ export const PopoverCountButton: React.FC<IProps> = ({
<Link className={className} to={url}> <Link className={className} to={url}>
<Button className="minimal"> <Button className="minimal">
<Icon icon={getIcon()} /> <Icon icon={getIcon()} />
<span>{countEl}</span> <Count count={count} />
</Button> </Button>
</Link> </Link>
</OverlayTrigger> </OverlayTrigger>

View file

@ -73,12 +73,14 @@ export const PerformerLink: React.FC<IPerformerLinkProps> = ({
interface IGroupLinkProps { interface IGroupLinkProps {
group: INamedObject; group: INamedObject;
linkType?: "scene"; description?: string;
linkType?: "scene" | "sub_group" | "details";
className?: string; className?: string;
} }
export const GroupLink: React.FC<IGroupLinkProps> = ({ export const GroupLink: React.FC<IGroupLinkProps> = ({
group, group,
description,
linkType = "scene", linkType = "scene",
className, className,
}) => { }) => {
@ -86,6 +88,10 @@ export const GroupLink: React.FC<IGroupLinkProps> = ({
switch (linkType) { switch (linkType) {
case "scene": case "scene":
return NavUtils.makeGroupScenesUrl(group); return NavUtils.makeGroupScenesUrl(group);
case "sub_group":
return NavUtils.makeSubGroupsUrl(group);
case "details":
return NavUtils.makeGroupUrl(group.id ?? "");
} }
}, [group, linkType]); }, [group, linkType]);
@ -93,7 +99,10 @@ export const GroupLink: React.FC<IGroupLinkProps> = ({
return ( return (
<CommonLinkComponent link={link} className={className}> <CommonLinkComponent link={link} className={className}>
{title} {title}{" "}
{description && (
<span className="group-description">({description})</span>
)}
</CommonLinkComponent> </CommonLinkComponent>
); );
}; };

View file

@ -234,29 +234,47 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
height: 5px; height: 5px;
} }
.card-check { .card-controls {
align-items: center;
display: flex;
left: 0.5rem; left: 0.5rem;
margin-top: -12px;
opacity: 0;
padding-left: 15px;
position: absolute; position: absolute;
top: 0.7rem; top: 0.7rem;
width: 1.2rem;
z-index: 1; z-index: 1;
}
.card-check,
.card-drag-handle {
height: 1.2rem;
opacity: 0;
width: 1.2rem;
@media (hover: none), (pointer: coarse) {
// always show card controls when hovering not supported
opacity: 0.25;
}
}
.card-drag-handle {
cursor: move;
}
.card-check {
padding-left: 15px;
&:checked { &:checked {
opacity: 0.75; opacity: 0.75;
} }
@media (hover: none), (pointer: coarse) { @media (hover: none), (pointer: coarse) {
// always show card check button when hovering not supported // and make it bigger when hovering not supported
opacity: 0.25;
// and make it bigger
width: 1.5rem; width: 1.5rem;
} }
} }
&:hover .card-check { &:hover .card-check,
&:hover .card-drag-handle {
opacity: 0.75; opacity: 0.75;
transition: opacity 0.5s; transition: opacity 0.5s;
} }

View file

@ -1388,6 +1388,60 @@ export const useGroupsDestroy = (input: GQL.GroupsDestroyMutationVariables) =>
}, },
}); });
export function useReorderSubGroupsMutation() {
return GQL.useReorderSubGroupsMutation({
update(cache) {
evictQueries(cache, [
GQL.FindGroupsDocument, // various filters
]);
},
});
}
export const useAddSubGroups = () => {
const [addSubGroups] = GQL.useAddGroupSubGroupsMutation({
update(cache, result) {
if (!result.data?.addGroupSubGroups) return;
evictTypeFields(cache, groupMutationImpactedTypeFields);
evictQueries(cache, groupMutationImpactedQueries);
},
});
return (containingGroupId: string, toAdd: GQL.GroupDescriptionInput[]) => {
return addSubGroups({
variables: {
input: {
containing_group_id: containingGroupId,
sub_groups: toAdd,
},
},
});
};
};
export const useRemoveSubGroups = () => {
const [removeSubGroups] = GQL.useRemoveGroupSubGroupsMutation({
update(cache, result) {
if (!result.data?.removeGroupSubGroups) return;
evictTypeFields(cache, groupMutationImpactedTypeFields);
evictQueries(cache, groupMutationImpactedQueries);
},
});
return (containingGroupId: string, removeIds: string[]) => {
return removeSubGroups({
variables: {
input: {
containing_group_id: containingGroupId,
sub_group_ids: removeIds,
},
},
});
};
};
const sceneMarkerMutationImpactedTypeFields = { const sceneMarkerMutationImpactedTypeFields = {
Tag: ["scene_marker_count"], Tag: ["scene_marker_count"],
}; };

View file

@ -4,6 +4,7 @@
"add_directory": "Add Directory", "add_directory": "Add Directory",
"add_entity": "Add {entityType}", "add_entity": "Add {entityType}",
"add_manual_date": "Add manual date", "add_manual_date": "Add manual date",
"add_sub_groups": "Add Sub-Groups",
"add_o": "Add O", "add_o": "Add O",
"add_play": "Add play", "add_play": "Add play",
"add_to_entity": "Add to {entityType}", "add_to_entity": "Add to {entityType}",
@ -91,6 +92,7 @@
"reload_scrapers": "Reload scrapers", "reload_scrapers": "Reload scrapers",
"remove": "Remove", "remove": "Remove",
"remove_date": "Remove date", "remove_date": "Remove date",
"remove_from_containing_group": "Remove from Group",
"remove_from_gallery": "Remove from Gallery", "remove_from_gallery": "Remove from Gallery",
"rename_gen_files": "Rename generated files", "rename_gen_files": "Rename generated files",
"rescan": "Rescan", "rescan": "Rescan",
@ -799,6 +801,9 @@
"websocket_connection_failed": "Unable to make websocket connection: see browser console for details", "websocket_connection_failed": "Unable to make websocket connection: see browser console for details",
"websocket_connection_reestablished": "Websocket connection re-established" "websocket_connection_reestablished": "Websocket connection re-established"
}, },
"containing_group": "Containing Group",
"containing_group_count": "Containing Group Count",
"containing_groups": "Containing Groups",
"countables": { "countables": {
"files": "{count, plural, one {File} other {Files}}", "files": "{count, plural, one {File} other {Files}}",
"galleries": "{count, plural, one {Gallery} other {Galleries}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}",
@ -1090,6 +1095,7 @@
"image_index": "Image #", "image_index": "Image #",
"images": "Images", "images": "Images",
"include_parent_tags": "Include parent tags", "include_parent_tags": "Include parent tags",
"include_sub_group_content": "Include sub-group content",
"include_sub_studio_content": "Include sub-studio content", "include_sub_studio_content": "Include sub-studio content",
"include_sub_studios": "Include subsidiary studios", "include_sub_studios": "Include subsidiary studios",
"include_sub_tag_content": "Include sub-tag content", "include_sub_tag_content": "Include sub-tag content",
@ -1424,6 +1430,11 @@
}, },
"studio_tags": "Studio Tags", "studio_tags": "Studio Tags",
"studios": "Studios", "studios": "Studios",
"sub_group": "Sub-Group",
"sub_group_count": "Sub-Group Count",
"sub_group_of": "Sub-group of {parent}",
"sub_group_order": "Sub-Group Order",
"sub_groups": "Sub-Groups",
"sub_tag_count": "Sub-Tag Count", "sub_tag_count": "Sub-Tag Count",
"sub_tag_of": "Sub-tag of {parent}", "sub_tag_of": "Sub-tag of {parent}",
"sub_tags": "Sub-Tags", "sub_tags": "Sub-Tags",

View file

@ -319,6 +319,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
// excluded only makes sense for includes and includes all // excluded only makes sense for includes and includes all
// so reset it for other modifiers // so reset it for other modifiers
if ( if (
this.value &&
value !== CriterionModifier.Includes && value !== CriterionModifier.Includes &&
value !== CriterionModifier.IncludesAll value !== CriterionModifier.IncludesAll
) { ) {

View file

@ -1,17 +1,44 @@
import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; import { CriterionModifier } from "src/core/generated-graphql";
import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion";
import { CriterionType } from "../types";
const inputType = "groups"; const inputType = "groups";
export const GroupsCriterionOption = new ILabeledIdCriterionOption( const modifierOptions = [
"groups", CriterionModifier.Includes,
"groups", CriterionModifier.Excludes,
false, CriterionModifier.IsNull,
inputType, CriterionModifier.NotNull,
() => new GroupsCriterion() ];
);
export class GroupsCriterion extends ILabeledIdCriterion { const defaultModifier = CriterionModifier.Includes;
constructor() {
super(GroupsCriterionOption); class BaseGroupsCriterionOption extends CriterionOption {
constructor(messageID: string, type: CriterionType) {
super({
messageID,
type,
modifierOptions,
defaultModifier,
inputType,
makeCriterion: () => new GroupsCriterion(this),
});
} }
} }
export const GroupsCriterionOption = new BaseGroupsCriterionOption(
"groups",
"groups"
);
export class GroupsCriterion extends IHierarchicalLabeledIdCriterion {}
export const ContainingGroupsCriterionOption = new BaseGroupsCriterionOption(
"containing_groups",
"containing_groups"
);
export const SubGroupsCriterionOption = new BaseGroupsCriterionOption(
"sub_groups",
"sub_groups"
);

View file

@ -488,6 +488,12 @@ export class ListFilterModel {
return ret; return ret;
} }
public setPageSize(pageSize: number) {
const ret = this.clone();
ret.itemsPerPage = pageSize;
return ret;
}
public changePage(page: number) { public changePage(page: number) {
const ret = this.clone(); const ret = this.clone();
ret.currentPage = page; ret.currentPage = page;

View file

@ -13,6 +13,10 @@ import { DisplayMode } from "./types";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
// import { StudioTagsCriterionOption } from "./criteria/tags"; // import { StudioTagsCriterionOption } from "./criteria/tags";
import { TagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags";
import {
ContainingGroupsCriterionOption,
SubGroupsCriterionOption,
} from "./criteria/groups";
const defaultSortBy = "name"; const defaultSortBy = "name";
@ -23,6 +27,7 @@ const sortByOptions = [
"duration", "duration",
"rating", "rating",
"tag_count", "tag_count",
"sub_group_order",
] ]
.map(ListFilterOptions.createSortBy) .map(ListFilterOptions.createSortBy)
.concat([ .concat([
@ -44,6 +49,10 @@ const criterionOptions = [
RatingCriterionOption, RatingCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,
createDateCriterionOption("date"), createDateCriterionOption("date"),
ContainingGroupsCriterionOption,
SubGroupsCriterionOption,
createMandatoryNumberCriterionOption("containing_group_count"),
createMandatoryNumberCriterionOption("sub_group_count"),
TagsCriterionOption, TagsCriterionOption,
createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("tag_count"),
createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("created_at"),

View file

@ -148,6 +148,10 @@ export type CriterionType =
| "studios" | "studios"
| "scenes" | "scenes"
| "groups" | "groups"
| "containing_groups"
| "containing_group_count"
| "sub_groups"
| "sub_group_count"
| "galleries" | "galleries"
| "birth_year" | "birth_year"
| "age" | "age"

View file

@ -45,8 +45,11 @@ export function getAggregateStudioId(state: IHasStudio[]) {
return ret; return ret;
} }
export function getAggregateIds(sortedLists: string[][]) { export function getAggregateIds<T>(
let ret: string[] = []; sortedLists: T[][],
isEqualFn: (a: T[], b: T[]) => boolean = isEqual
) {
let ret: T[] = [];
let first = true; let first = true;
sortedLists.forEach((l) => { sortedLists.forEach((l) => {
@ -54,7 +57,7 @@ export function getAggregateIds(sortedLists: string[][]) {
ret = l; ret = l;
first = false; first = false;
} else { } else {
if (!isEqual(ret, l)) { if (!isEqualFn(ret, l)) {
ret = []; ret = [];
} }
} }

View file

@ -12,7 +12,12 @@ import {
TagsCriterionOption, TagsCriterionOption,
} from "src/models/list-filter/criteria/tags"; } from "src/models/list-filter/criteria/tags";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { GroupsCriterion } from "src/models/list-filter/criteria/groups"; import {
ContainingGroupsCriterionOption,
GroupsCriterion,
GroupsCriterionOption,
SubGroupsCriterionOption,
} from "src/models/list-filter/criteria/groups";
import { import {
Criterion, Criterion,
CriterionOption, CriterionOption,
@ -214,10 +219,12 @@ const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {
const makeGroupScenesUrl = (group: Partial<GQL.GroupDataFragment>) => { const makeGroupScenesUrl = (group: Partial<GQL.GroupDataFragment>) => {
if (!group.id) return "#"; if (!group.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
const criterion = new GroupsCriterion(); const criterion = new GroupsCriterion(GroupsCriterionOption);
criterion.value = [ criterion.value = {
{ id: group.id, label: group.name || `Group ${group.id}` }, items: [{ id: group.id, label: group.name || `Group ${group.id}` }],
]; excluded: [],
depth: 0,
};
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
}; };
@ -382,6 +389,46 @@ const makePhotographerImagesUrl = (photographer: string) => {
return `/images?${filter.makeQueryParameters()}`; return `/images?${filter.makeQueryParameters()}`;
}; };
const makeGroupUrl = (id: string) => {
return `/groups/${id}`;
};
const makeContainingGroupsUrl = (group: Partial<GQL.SlimGroupDataFragment>) => {
if (!group.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);
const criterion = new GroupsCriterion(SubGroupsCriterionOption);
criterion.value = {
items: [
{
id: group.id,
label: group.name || `Group ${group.id}`,
},
],
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/groups?${filter.makeQueryParameters()}`;
};
const makeSubGroupsUrl = (group: INamedObject) => {
if (!group.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);
const criterion = new GroupsCriterion(ContainingGroupsCriterionOption);
criterion.value = {
items: [
{
id: group.id,
label: group.name || `Group ${group.id}`,
},
],
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/groups?${filter.makeQueryParameters()}`;
};
export function handleUnsavedChanges( export function handleUnsavedChanges(
intl: IntlShape, intl: IntlShape,
basepath: string, basepath: string,
@ -409,6 +456,7 @@ const NavUtils = {
makeStudioGroupsUrl: makeStudioGroupsUrl, makeStudioGroupsUrl: makeStudioGroupsUrl,
makeStudioPerformersUrl, makeStudioPerformersUrl,
makeTagUrl, makeTagUrl,
makeGroupUrl,
makeParentTagsUrl, makeParentTagsUrl,
makeChildTagsUrl, makeChildTagsUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
@ -427,6 +475,8 @@ const NavUtils = {
makePhotographerGalleriesUrl, makePhotographerGalleriesUrl,
makePhotographerImagesUrl, makePhotographerImagesUrl,
makeDirectorGroupsUrl, makeDirectorGroupsUrl,
makeContainingGroupsUrl,
makeSubGroupsUrl,
}; };
export default NavUtils; export default NavUtils;