diff --git a/graphql/documents/mutations/tag.graphql b/graphql/documents/mutations/tag.graphql index 0b3d72451..20e3b4b81 100644 --- a/graphql/documents/mutations/tag.graphql +++ b/graphql/documents/mutations/tag.graphql @@ -17,3 +17,9 @@ mutation TagUpdate($input: TagUpdateInput!) { ...TagData } } + +mutation TagsMerge($source: [ID!]!, $destination: ID!) { + tagsMerge(input: { source: $source, destination: $destination }) { + ...TagData + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 993bfdd97..623e4aeb8 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -197,6 +197,7 @@ type Mutation { tagUpdate(input: TagUpdateInput!): Tag tagDestroy(input: TagDestroyInput!): Boolean! tagsDestroy(ids: [ID!]!): Boolean! + tagsMerge(input: TagsMergeInput!): Tag """Change general configuration options""" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index a94f1509b..46f8c25cd 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -37,4 +37,9 @@ input TagDestroyInput { type FindTagsResultType { count: Int! tags: [Tag!]! -} \ No newline at end of file +} + +input TagsMergeInput { + source: [ID!]! + destination: ID! +} diff --git a/pkg/api/resolver_mutation_tag.go b/pkg/api/resolver_mutation_tag.go index 8b8682683..94292a7ba 100644 --- a/pkg/api/resolver_mutation_tag.go +++ b/pkg/api/resolver_mutation_tag.go @@ -212,3 +212,44 @@ func (r *mutationResolver) TagsDestroy(ctx context.Context, tagIDs []string) (bo return true, nil } + +func (r *mutationResolver) TagsMerge(ctx context.Context, input models.TagsMergeInput) (*models.Tag, error) { + source, err := utils.StringSliceToIntSlice(input.Source) + if err != nil { + return nil, err + } + + destination, err := strconv.Atoi(input.Destination) + if err != nil { + return nil, err + } + + if len(source) == 0 { + return nil, nil + } + + var t *models.Tag + if err := r.withTxn(ctx, func(repo models.Repository) error { + qb := repo.Tag() + + var err error + t, err = qb.Find(destination) + if err != nil { + return err + } + + if t == nil { + return fmt.Errorf("Tag with ID %d not found", destination) + } + + if err = qb.Merge(source, destination); err != nil { + return err + } + + return nil + }); err != nil { + return nil, err + } + + return t, nil +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 726416a99..986074405 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -358,13 +358,13 @@ func (_m *PerformerReaderWriter) GetStashIDs(performerID int) ([]*models.StashID return r0, r1 } -// GetTagIDs provides a mock function with given fields: sceneID -func (_m *PerformerReaderWriter) GetTagIDs(sceneID int) ([]int, error) { - ret := _m.Called(sceneID) +// GetTagIDs provides a mock function with given fields: performerID +func (_m *PerformerReaderWriter) GetTagIDs(performerID int) ([]int, error) { + ret := _m.Called(performerID) var r0 []int if rf, ok := ret.Get(0).(func(int) []int); ok { - r0 = rf(sceneID) + r0 = rf(performerID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) @@ -373,7 +373,7 @@ func (_m *PerformerReaderWriter) GetTagIDs(sceneID int) ([]int, error) { var r1 error if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(sceneID) + r1 = rf(performerID) } else { r1 = ret.Error(1) } @@ -508,13 +508,13 @@ func (_m *PerformerReaderWriter) UpdateStashIDs(performerID int, stashIDs []mode return r0 } -// UpdateTags provides a mock function with given fields: sceneID, tagIDs -func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error { - ret := _m.Called(sceneID, tagIDs) +// UpdateTags provides a mock function with given fields: performerID, tagIDs +func (_m *PerformerReaderWriter) UpdateTags(performerID int, tagIDs []int) error { + ret := _m.Called(performerID, tagIDs) var r0 error if rf, ok := ret.Get(0).(func(int, []int) error); ok { - r0 = rf(sceneID, tagIDs) + r0 = rf(performerID, tagIDs) } else { r0 = ret.Error(0) } diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index e6c90e932..f7cabb8ce 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -360,6 +360,20 @@ func (_m *TagReaderWriter) GetImage(tagID int) ([]byte, error) { return r0, r1 } +// Merge provides a mock function with given fields: source, destination +func (_m *TagReaderWriter) Merge(source []int, destination int) error { + ret := _m.Called(source, destination) + + var r0 error + if rf, ok := ret.Get(0).(func([]int, int) error); ok { + r0 = rf(source, destination) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Query provides a mock function with given fields: tagFilter, findFilter func (_m *TagReaderWriter) Query(tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) { ret := _m.Called(tagFilter, findFilter) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index a7f374a77..4d3e0d84e 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -28,6 +28,7 @@ type TagWriter interface { UpdateImage(tagID int, image []byte) error DestroyImage(tagID int) error UpdateAliases(tagID int, aliases []string) error + Merge(source []int, destination int) error } type TagReaderWriter interface { diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index eb219ea70..67d805ea5 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -5,6 +5,7 @@ package sqlite_test import ( "context" "database/sql" + "errors" "fmt" "io/ioutil" "os" @@ -342,6 +343,16 @@ func withTxn(f func(r models.Repository) error) error { return t.WithTxn(context.TODO(), f) } +func withRollbackTxn(f func(r models.Repository) error) error { + var ret error + withTxn(func(repo models.Repository) error { + ret = f(repo) + return errors.New("fake error for rollback") + }) + + return ret +} + func testTeardown(databaseFile string) { err := database.DB.Close() diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 40c8f055d..d05fb4c9b 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -536,3 +536,64 @@ func (qb *tagQueryBuilder) GetAliases(tagID int) ([]string, error) { func (qb *tagQueryBuilder) UpdateAliases(tagID int, aliases []string) error { return qb.aliasRepository().replace(tagID, aliases) } + +func (qb *tagQueryBuilder) Merge(source []int, destination int) error { + if len(source) == 0 { + return nil + } + + inBinding := getInBinding(len(source)) + + args := []interface{}{destination} + for _, id := range source { + if id == destination { + return errors.New("cannot merge where source == destination") + } + args = append(args, id) + } + + tagTables := map[string]string{ + scenesTagsTable: sceneIDColumn, + "scene_markers_tags": "scene_marker_id", + galleriesTagsTable: galleryIDColumn, + imagesTagsTable: imageIDColumn, + "performers_tags": "performer_id", + } + + tagArgs := append(args, destination) + for table, idColumn := range tagTables { + _, err := qb.tx.Exec(`UPDATE `+table+` +SET tag_id = ? +WHERE tag_id IN `+inBinding+` +AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.tag_id = ?)`, + tagArgs..., + ) + if err != nil { + return err + } + } + + _, err := qb.tx.Exec("UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) + if err != nil { + return err + } + + _, err = qb.tx.Exec("INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) + if err != nil { + return err + } + + _, err = qb.tx.Exec("UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) + if err != nil { + return err + } + + for _, id := range source { + err = qb.Destroy(id) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index b0ccab2ac..c346d5032 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -600,6 +600,116 @@ func TestTagUpdateAlias(t *testing.T) { } } +func TestTagMerge(t *testing.T) { + assert := assert.New(t) + + // merge tests - perform these in a transaction that we'll rollback + if err := withRollbackTxn(func(r models.Repository) error { + qb := r.Tag() + + // try merging into same tag + err := qb.Merge([]int{tagIDs[tagIdx1WithScene]}, tagIDs[tagIdx1WithScene]) + assert.NotNil(err) + + // merge everything into tagIdxWithScene + srcIdxs := []int{ + tagIdx1WithScene, + tagIdx2WithScene, + tagIdxWithPrimaryMarker, + tagIdxWithMarker, + tagIdxWithCoverImage, + tagIdxWithImage, + tagIdx1WithImage, + tagIdx2WithImage, + tagIdxWithPerformer, + tagIdx1WithPerformer, + tagIdx2WithPerformer, + tagIdxWithGallery, + tagIdx1WithGallery, + tagIdx2WithGallery, + } + var srcIDs []int + for _, idx := range srcIdxs { + srcIDs = append(srcIDs, tagIDs[idx]) + } + + destID := tagIDs[tagIdxWithScene] + if err = qb.Merge(srcIDs, destID); err != nil { + return err + } + + // ensure other tags are deleted + for _, tagId := range srcIDs { + t, err := qb.Find(tagId) + if err != nil { + return err + } + + assert.Nil(t) + } + + // ensure aliases are set on the destination + destAliases, err := qb.GetAliases(destID) + if err != nil { + return err + } + for _, tagIdx := range srcIdxs { + assert.Contains(destAliases, getTagStringValue(tagIdx, "Name")) + } + + // ensure scene points to new tag + sceneTagIDs, err := r.Scene().GetTagIDs(sceneIDs[sceneIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(sceneTagIDs, destID) + + // ensure marker points to new tag + marker, err := r.SceneMarker().Find(markerIDs[markerIdxWithScene]) + if err != nil { + return err + } + + assert.Equal(destID, marker.PrimaryTagID) + + markerTagIDs, err := r.SceneMarker().GetTagIDs(marker.ID) + if err != nil { + return err + } + + assert.Contains(markerTagIDs, destID) + + // ensure image points to new tag + imageTagIDs, err := r.Image().GetTagIDs(imageIDs[imageIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(imageTagIDs, destID) + + // ensure gallery points to new tag + galleryTagIDs, err := r.Gallery().GetTagIDs(galleryIDs[galleryIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(galleryTagIDs, destID) + + // ensure performer points to new tag + performerTagIDs, err := r.Gallery().GetTagIDs(performerIDs[performerIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(performerTagIDs, destID) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + // TODO Create // TODO Update // TODO Destroy diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index 7fa18c524..a80ff31b0 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added merge tags functionality. ([#1481](https://github.com/stashapp/stash/pull/1481)) * Added support for triggering plugin tasks during operations. ([#1452](https://github.com/stashapp/stash/pull/1452)) * Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397)) * Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412)) diff --git a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx index 3f342ac8e..999e5c728 100644 --- a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx +++ b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx @@ -19,6 +19,7 @@ interface IProps { onClearImage?: () => void; onClearBackImage?: () => void; acceptSVG?: boolean; + customButtons?: JSX.Element; } export const DetailsEditNavbar: React.FC = (props: IProps) => { @@ -165,6 +166,7 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { "" )} {renderAutoTagButton()} + {props.customButtons} {renderSaveButton()} {renderDeleteButton()} {renderDeleteAlert()} diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index bd996017c..c8f3d456c 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -474,14 +474,20 @@ export const MovieSelect: React.FC = (props) => { ); }; -export const TagSelect: React.FC = (props) => { +export const TagSelect: React.FC = ( + props +) => { const [tagAliases, setTagAliases] = useState>({}); const [allAliases, setAllAliases] = useState([]); const { data, loading } = useAllTagsForFilter(); const [createTag] = useTagCreate(); const placeholder = props.noSelectionString ?? "Select tags..."; - const tags = useMemo(() => data?.allTags ?? [], [data?.allTags]); + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + const tags = useMemo( + () => (data?.allTags ?? []).filter((tag) => !exclude.includes(tag.id)), + [data?.allTags, exclude] + ); useEffect(() => { // build the tag aliases map @@ -584,7 +590,7 @@ export const TagSelect: React.FC = (props) => { placeholder={placeholder} isLoading={loading} onCreate={onCreate} - closeMenuOnSelect={false} + closeMenuOnSelect={!props.isMulti} /> ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 2758ddee4..b08296978 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab } from "react-bootstrap"; +import { Tabs, Tab, Dropdown } from "react-bootstrap"; import React, { useEffect, useState } from "react"; import { useParams, useHistory } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -18,6 +18,7 @@ import { DetailsEditNavbar, Modal, LoadingIndicator, + Icon, } from "src/components/Shared"; import { useToast } from "src/hooks"; import { TagScenesPanel } from "./TagScenesPanel"; @@ -27,6 +28,7 @@ import { TagPerformersPanel } from "./TagPerformersPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; +import { TagMergeModal } from "./TagMergeDialog"; interface ITabParams { id?: string; @@ -43,6 +45,7 @@ export const Tag: React.FC = () => { // Editing state const [isEditing, setIsEditing] = useState(isNew); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); // Editing tag state const [image, setImage] = useState(); @@ -213,6 +216,44 @@ export const Tag: React.FC = () => { } } + function renderMergeButton() { + return ( + + Merge... + + setMergeType("from")} + > + + + ... + + setMergeType("into")} + > + + + ... + + + + ); + } + + function renderMergeDialog() { + if (!tag || !mergeType) return; + return ( + setMergeType(undefined)} + show={!!mergeType} + mergeType={mergeType} + /> + ); + } + return (
{ onClearImage={() => {}} onAutoTag={onAutoTag} onDelete={onDelete} + customButtons={renderMergeButton()} /> ) : ( @@ -291,6 +333,7 @@ export const Tag: React.FC = () => {
)} {renderDeleteAlert()} + {renderMergeDialog()}
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx new file mode 100644 index 000000000..d0452c8be --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx @@ -0,0 +1,136 @@ +import { Form, Col, Row } from "react-bootstrap"; +import React, { useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { Modal, TagSelect } from "src/components/Shared"; +import { FormUtils } from "src/utils"; +import { useTagsMerge } from "src/core/StashService"; +import { useIntl } from "react-intl"; +import { useToast } from "src/hooks"; +import { useHistory } from "react-router"; + +interface ITagMergeModalProps { + show: boolean; + onClose: () => void; + tag: Pick; + mergeType: "from" | "into"; +} + +export const TagMergeModal: React.FC = ({ + show, + onClose, + tag, + mergeType, +}) => { + const [srcIds, setSrcIds] = useState([]); + const [destId, setDestId] = useState(null); + const [running, setRunning] = useState(false); + + const [mergeTags] = useTagsMerge(); + + const intl = useIntl(); + const Toast = useToast(); + const history = useHistory(); + + const title = intl.formatMessage({ + id: mergeType === "from" ? "actions.merge_from" : "actions.merge_into", + }); + + async function onMerge() { + const source = mergeType === "from" ? srcIds : [tag.id]; + const destination = mergeType === "from" ? tag.id : destId; + + if (!destination) return; + + try { + setRunning(true); + const result = await mergeTags({ + variables: { + source, + destination, + }, + }); + if (result.data?.tagsMerge) { + Toast.success({ + content: intl.formatMessage({ id: "toast.merged_tags" }), + }); + onClose(); + history.push(`/tags/${destination}`); + } + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return ( + (mergeType === "from" && srcIds.length > 0) || + (mergeType === "into" && destId) + ); + } + + return ( + onMerge() }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ {mergeType === "from" && ( + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge_tags.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSrcIds(items.map((item) => item.id))} + ids={srcIds} + excludeIds={tag?.id ? [tag.id] : []} + /> + + + )} + {mergeType === "into" && ( + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge_tags.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestId(items[0]?.id)} + ids={destId ? [destId] : undefined} + excludeIds={tag?.id ? [tag.id] : []} + /> + + + )} +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index 3cb871859..a459874ec 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -39,3 +39,7 @@ margin-bottom: 4rem; } } + +#tag-merge-menu .dropdown-item { + align-items: center; +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 94418bb0e..9325cc21e 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -680,6 +680,11 @@ export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) => update: deleteCache(tagMutationImpactedQueries), }); +export const useTagsMerge = () => + GQL.useTagsMergeMutation({ + update: deleteCache(tagMutationImpactedQueries), + }); + export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) => GQL.useConfigureGeneralMutation({ variables: { input }, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2e739b50d..6305c8929 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -42,6 +42,8 @@ "import": "Import…", "import_from_file": "Import from file", "merge": "Merge", + "merge_from": "Merge from", + "merge_into": "Merge into", "not_running": "not running", "overwrite": "Overwrite", "play_random": "Play Random", @@ -394,6 +396,10 @@ "edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Include related objects in export", "export_title": "Export", + "merge_tags": { + "destination": "Destination", + "source": "Source" + }, "scene_gen": { "image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)", "markers": "Markers (20 second videos which begin at the given timecode)", @@ -567,6 +573,7 @@ "delete_entity": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", + "merged_tags": "Merged tags", "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "started_auto_tagging": "Started auto tagging", "updated_entity": "Updated {entity}"