mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
96fdd94a01
commit
bcf0fda7ac
99 changed files with 5388 additions and 935 deletions
|
|
@ -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!
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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!]!
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,7 +352,38 @@ 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)
|
||||||
|
}); err != nil {
|
||||||
|
var subError group.SubGroupNotExistError
|
||||||
|
if errors.As(err, &subError) {
|
||||||
|
missingSub := subError.MissingSubGroup()
|
||||||
|
pendingSubs[missingSub] = append(pendingSubs[missingSub], groupJSON)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Errorf("[groups] <%s> failed to import: %v", fi.Name(), err)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
ReaderWriter: r.Group,
|
||||||
StudioWriter: r.Studio,
|
StudioWriter: r.Studio,
|
||||||
TagWriter: r.Tag,
|
TagWriter: r.Tag,
|
||||||
|
|
@ -359,14 +391,31 @@ func (t *ImportTask) ImportGroups(ctx context.Context) {
|
||||||
MissingRefBehaviour: t.MissingRefBehaviour,
|
MissingRefBehaviour: t.MissingRefBehaviour,
|
||||||
}
|
}
|
||||||
|
|
||||||
return performImport(ctx, groupImporter, t.DuplicateBehaviour)
|
// first phase: return error if parent does not exist
|
||||||
}); err != nil {
|
if !fail {
|
||||||
logger.Errorf("[groups] <%s> import failed: %v", fi.Name(), err)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to create containing group <%s>: %v", containingGroupJSON.Name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("[groups] import complete")
|
delete(pendingSub, groupJSON.Name)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ImportTask) ImportFiles(ctx context.Context) {
|
func (t *ImportTask) ImportFiles(ctx context.Context) {
|
||||||
|
|
|
||||||
41
pkg/group/create.go
Normal file
41
pkg/group/create.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
33
pkg/group/reorder.go
Normal 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
46
pkg/group/service.go
Normal 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
112
pkg/group/update.go
Normal 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
117
pkg/group/validate.go
Normal 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 })
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ 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"`
|
||||||
|
|
@ -24,6 +29,7 @@ type Group struct {
|
||||||
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"`
|
||||||
|
SubGroups []SubGroupDescription `json:"sub_groups,omitempty"`
|
||||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,6 +46,18 @@ 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
|
||||||
|
|
@ -55,6 +70,8 @@ type GroupPartial struct {
|
||||||
Synopsis OptionalString
|
Synopsis OptionalString
|
||||||
URLs *UpdateStrings
|
URLs *UpdateStrings
|
||||||
TagIDs *UpdateIDs
|
TagIDs *UpdateIDs
|
||||||
|
ContainingGroups *UpdateGroupDescriptions
|
||||||
|
SubGroups *UpdateGroupDescriptions
|
||||||
CreatedAt OptionalTime
|
CreatedAt OptionalTime
|
||||||
UpdatedAt OptionalTime
|
UpdatedAt OptionalTime
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
222
pkg/sqlite/filter_hierarchical.go
Normal file
222
pkg/sqlite/filter_hierarchical.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil},
|
×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil},
|
||||||
×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil},
|
×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil},
|
||||||
|
|
||||||
|
|
|
||||||
457
pkg/sqlite/group_relationships.go
Normal file
457
pkg/sqlite/group_relationships.go
Normal 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
13
pkg/sqlite/migrations/67_group_relationships.up.sql
Normal file
13
pkg/sqlite/migrations/67_group_relationships.up.sql
Normal 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`);
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -391,6 +398,7 @@ 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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ var (
|
||||||
|
|
||||||
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 (
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
|
×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
|
||||||
×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},
|
×tampCriterionHandler{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...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
61
ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx
Normal file
61
ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
121
ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx
Normal file
121
ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
137
ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx
Normal file
137
ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
ui/v2.5/src/components/Groups/GroupTag.tsx
Normal file
28
ui/v2.5/src/components/Groups/GroupTag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx
Normal file
110
ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{showEditFilter && (
|
||||||
<ListFilter
|
<ListFilter
|
||||||
onFilterUpdate={setFilter}
|
onFilterUpdate={setFilter}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
openFilterDialog={() => showEditFilter()}
|
openFilterDialog={() => showEditFilter()}
|
||||||
view={view}
|
view={view}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<ListOperationButtons
|
<ListOperationButtons
|
||||||
onSelectAll={onSelectAll}
|
onSelectAll={onSelectAll}
|
||||||
onSelectNone={onSelectNone}
|
onSelectNone={onSelectNone}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +148,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
|
||||||
}
|
}
|
||||||
}, [addKeybinds, result, effectiveFilter, selectedIds]);
|
}, [addKeybinds, result, effectiveFilter, selectedIds]);
|
||||||
|
|
||||||
|
const operations = useMemo(() => {
|
||||||
async function onOperationClicked(o: IItemListOperation<T>) {
|
async function onOperationClicked(o: IItemListOperation<T>) {
|
||||||
await o.onClick(result, effectiveFilter, selectedIds);
|
await o.onClick(result, effectiveFilter, selectedIds);
|
||||||
if (o.postRefetch) {
|
if (o.postRefetch) {
|
||||||
|
|
@ -149,7 +156,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const operations = otherOperations?.map((o) => ({
|
return otherOperations?.map((o) => ({
|
||||||
text: o.text,
|
text: o.text,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onOperationClicked(o);
|
onOperationClicked(o);
|
||||||
|
|
@ -164,6 +171,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
|
||||||
icon: o.icon,
|
icon: o.icon,
|
||||||
buttonVariant: o.buttonVariant,
|
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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,19 +195,12 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.length > 0) {
|
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<OperationDropdown>
|
||||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
{options.length > 0 ? options : undefined}
|
||||||
<Icon icon={faEllipsisH} />
|
</OperationDropdown>
|
||||||
</Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu className="bg-secondary text-white">
|
|
||||||
{options}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ export enum View {
|
||||||
StudioChildren = "studio_children",
|
StudioChildren = "studio_children",
|
||||||
|
|
||||||
GroupScenes = "group_scenes",
|
GroupScenes = "group_scenes",
|
||||||
|
GroupSubGroups = "group_sub_groups",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
143
ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts
Normal file
143
ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,15 +52,7 @@ const Select: React.FC<IMultiSetProps> = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MultiSet: React.FC<IMultiSetProps> = (props) => {
|
function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) {
|
||||||
const intl = useIntl();
|
|
||||||
const modes = [
|
|
||||||
GQL.BulkUpdateIdMode.Set,
|
|
||||||
GQL.BulkUpdateIdMode.Add,
|
|
||||||
GQL.BulkUpdateIdMode.Remove,
|
|
||||||
];
|
|
||||||
|
|
||||||
function getModeText(mode: GQL.BulkUpdateIdMode) {
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case GQL.BulkUpdateIdMode.Set:
|
case GQL.BulkUpdateIdMode.Set:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
|
|
@ -75,47 +67,81 @@ export const MultiSet: React.FC<IMultiSetProps> = (props) => {
|
||||||
defaultMessage: "Remove",
|
defaultMessage: "Remove",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSetMode(mode: GQL.BulkUpdateIdMode) {
|
export const MultiSetModeButton: React.FC<{
|
||||||
if (mode === props.mode) {
|
mode: GQL.BulkUpdateIdMode;
|
||||||
return;
|
active: boolean;
|
||||||
}
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ mode, active, onClick, disabled }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
// if going to Set, set the existing ids
|
|
||||||
if (mode === GQL.BulkUpdateIdMode.Set && props.existingIds) {
|
|
||||||
props.onUpdate(props.existingIds);
|
|
||||||
// if going from Set, wipe the ids
|
|
||||||
} else if (
|
|
||||||
mode !== GQL.BulkUpdateIdMode.Set &&
|
|
||||||
props.mode === GQL.BulkUpdateIdMode.Set
|
|
||||||
) {
|
|
||||||
props.onUpdate([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onSetMode(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModeButton(mode: GQL.BulkUpdateIdMode) {
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={mode}
|
key={mode}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
active={props.mode === mode}
|
active={active}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onSetMode(mode)}
|
onClick={onClick}
|
||||||
disabled={props.disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{getModeText(mode)}
|
{getModeText(intl, mode)}
|
||||||
</Button>
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if going to Set, set the existing ids
|
||||||
|
if (m === GQL.BulkUpdateIdMode.Set && existingIds) {
|
||||||
|
onUpdate(existingIds);
|
||||||
|
// if going from Set, wipe the ids
|
||||||
|
} else if (
|
||||||
|
m !== GQL.BulkUpdateIdMode.Set &&
|
||||||
|
mode === GQL.BulkUpdateIdMode.Set
|
||||||
|
) {
|
||||||
|
onUpdate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSetMode(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue