diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 51a87bf4f..be11a1d84 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -36,6 +36,7 @@ input StudioCreateInput { url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID + child_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] @@ -58,6 +59,7 @@ input StudioUpdateInput { url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID + child_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index c7af918a1..f78e866b1 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -14,6 +14,63 @@ import ( ) // used to refetch studio after hooks run + +func clearRemovedChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { + currentChildren, err := qb.FindChildren(ctx, parentStudioID) + if err != nil { + return err + } + + newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs)) + for _, childStudioID := range childStudioIDs { + newChildStudioIDs[childStudioID] = struct{}{} + } + + for _, currentChild := range currentChildren { + if _, keep := newChildStudioIDs[currentChild.ID]; keep { + continue + } + + clearParentPartial := models.NewStudioPartial() + clearParentPartial.ID = currentChild.ID + clearParentPartial.ParentID = models.NewOptionalIntPtr(nil) + + if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil { + return err + } + } + + return nil +} + +func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { + if err := clearRemovedChildStudios(ctx, qb, parentStudioID, childStudioIDs); err != nil { + return err + } + + newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs)) + for _, childStudioID := range childStudioIDs { + if _, found := newChildStudioIDs[childStudioID]; found { + continue + } + newChildStudioIDs[childStudioID] = struct{}{} + + childPartial := models.NewStudioPartial() + childPartial.ID = childStudioID + childPartial.ParentID = models.NewOptionalInt(parentStudioID) + + if err := studio.ValidateModify(ctx, childPartial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, childPartial); err != nil { + return err + } + } + + return nil +} + func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.Find(ctx, id) @@ -62,6 +119,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } + + childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds) + if err != nil { + return nil, fmt.Errorf("converting child ids: %w", err) + } newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string @@ -93,6 +155,12 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio } } + if input.ChildIds != nil { + if err := setChildStudios(ctx, qb, newStudio.ID, childStudioIDs); err != nil { + return err + } + } + return nil }); err != nil { return nil, err @@ -135,6 +203,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting tag ids: %w", err) } + childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds) + if err != nil { + return nil, fmt.Errorf("converting child ids: %w", err) + } + if translator.hasField("urls") { // ensure url not included in the input if err := validateNoLegacyURLs(translator); err != nil { @@ -197,6 +270,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + if translator.hasField("child_ids") { + if err := clearRemovedChildStudios(ctx, qb, studioID, childStudioIDs); err != nil { + return err + } + } + if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil { return err } @@ -212,6 +291,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + if translator.hasField("child_ids") { + if err := setChildStudios(ctx, qb, studioID, childStudioIDs); err != nil { + return err + } + } + return nil }); err != nil { return nil, err diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 7ad8719ac..06c4b2cfd 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -62,6 +62,7 @@ type StudioCreateInput struct { URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` + ChildIds []string `json:"child_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` @@ -82,6 +83,7 @@ type StudioUpdateInput struct { URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` + ChildIds []string `json:"child_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index b1de160b1..020b66f38 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -57,12 +57,14 @@ export const StudioEditPanel: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [parentStudio, setParentStudio] = useState(null); + const [childStudios, setChildStudios] = useState([]); const schema = yup.object({ name: yup.string().required(), urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), + child_ids: yup.array(yup.string().required()).defined(), aliases: yupRequiredStringArray(intl).defined(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), @@ -77,6 +79,7 @@ export const StudioEditPanel: React.FC = ({ urls: studio.urls ?? [], details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, + child_ids: (studio.child_studios ?? []).map((child) => child.id), aliases: studio.aliases ?? [], tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, @@ -112,6 +115,14 @@ export const StudioEditPanel: React.FC = ({ formik.setFieldValue("parent_id", item ? item.id : null); } + function onSetChildStudios(items: Studio[]) { + setChildStudios(items); + formik.setFieldValue( + "child_ids", + items.map((item) => item.id) + ); + } + const encodingImage = ImageUtils.usePasteImage((imageData) => formik.setFieldValue("image", imageData) ); @@ -128,6 +139,17 @@ export const StudioEditPanel: React.FC = ({ ); }, [studio.parent_studio]); + useEffect(() => { + setChildStudios( + (studio.child_studios ?? []).map((childStudio) => ({ + id: childStudio.id, + name: childStudio.name, + aliases: [], + image_path: childStudio.image_path, + })) + ); + }, [studio.child_studios]); + useEffect(() => { setImage(formik.values.image); }, [formik.values.image, setImage]); @@ -199,12 +221,35 @@ export const StudioEditPanel: React.FC = ({ onSetParentStudio(items.length > 0 ? items[0] : null) } values={parentStudio ? [parentStudio] : []} + excludeIds={[ + ...(studio?.id ? [studio.id] : []), + ...formik.values.child_ids, + ]} /> ); return renderField("parent_id", title, control); } + function renderSubStudiosField() { + const title = intl.formatMessage({ id: "subsidiary_studios" }); + const control = ( + + formik.values.child_ids.includes(childStudio.id) + )} + excludeIds={[ + ...(studio?.id ? [studio.id] : []), + ...(formik.values.parent_id ? [formik.values.parent_id] : []), + ]} + /> + ); + + return renderField("child_ids", title, control); + } + function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl()); @@ -246,6 +291,7 @@ export const StudioEditPanel: React.FC = ({ {renderStringListField("urls")} {renderInputField("details", "textarea")} {renderParentStudioField()} + {renderSubStudiosField()} {renderTagsField()} {renderStashIDsField( "stash_ids",