Merge tags functionality (#1481)

* Add API to merge tags

Add new API endpoint, `tagsMerge(source, destination)` to merge multiple
tags into a single one. The "sources" must be provided as a list of ids
and the destination as a single id. All usages of the source tags
(scenes, markers (primary and additional), images, galleries and
performers) will be updated to the destination tag, all aliases of the
source tags will be updated to the destination, and the name of the
source will be added as alias to the destination as well.

* Add merge tag UI
* Add unit tests
* Update test mocks
* Update internationalisation
* Add changelog entry

Co-authored-by: gitgiggety <gitgiggety@outlook.com>
This commit is contained in:
WithoutPants 2021-06-16 14:33:54 +10:00 committed by GitHub
parent 45f4a5ba81
commit 4fe4da6c01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 468 additions and 14 deletions

View file

@ -17,3 +17,9 @@ mutation TagUpdate($input: TagUpdateInput!) {
...TagData
}
}
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
tagsMerge(input: { source: $source, destination: $destination }) {
...TagData
}
}

View file

@ -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!

View file

@ -37,4 +37,9 @@ input TagDestroyInput {
type FindTagsResultType {
count: Int!
tags: [Tag!]!
}
}
input TagsMergeInput {
source: [ID!]!
destination: ID!
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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 {

View file

@ -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()

View file

@ -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
}

View file

@ -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

View file

@ -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))

View file

@ -19,6 +19,7 @@ interface IProps {
onClearImage?: () => void;
onClearBackImage?: () => void;
acceptSVG?: boolean;
customButtons?: JSX.Element;
}
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
@ -165,6 +166,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
""
)}
{renderAutoTagButton()}
{props.customButtons}
{renderSaveButton()}
{renderDeleteButton()}
{renderDeleteAlert()}

View file

@ -474,14 +474,20 @@ export const MovieSelect: React.FC<IFilterProps> = (props) => {
);
};
export const TagSelect: React.FC<IFilterProps> = (props) => {
export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
props
) => {
const [tagAliases, setTagAliases] = useState<Record<string, string[]>>({});
const [allAliases, setAllAliases] = useState<string[]>([]);
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<IFilterProps> = (props) => {
placeholder={placeholder}
isLoading={loading}
onCreate={onCreate}
closeMenuOnSelect={false}
closeMenuOnSelect={!props.isMulti}
/>
);
};

View file

@ -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<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
// Editing tag state
const [image, setImage] = useState<string | null>();
@ -213,6 +216,44 @@ export const Tag: React.FC = () => {
}
}
function renderMergeButton() {
return (
<Dropdown drop="up">
<Dropdown.Toggle variant="secondary">Merge...</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white" id="tag-merge-menu">
<Dropdown.Item
className="bg-secondary text-white"
onClick={() => setMergeType("from")}
>
<Icon icon="sign-in-alt" />
<FormattedMessage id="actions.merge_from" />
...
</Dropdown.Item>
<Dropdown.Item
className="bg-secondary text-white"
onClick={() => setMergeType("into")}
>
<Icon icon="sign-out-alt" />
<FormattedMessage id="actions.merge_into" />
...
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
function renderMergeDialog() {
if (!tag || !mergeType) return;
return (
<TagMergeModal
tag={tag}
onClose={() => setMergeType(undefined)}
show={!!mergeType}
mergeType={mergeType}
/>
);
}
return (
<div className="row">
<div
@ -243,6 +284,7 @@ export const Tag: React.FC = () => {
onClearImage={() => {}}
onAutoTag={onAutoTag}
onDelete={onDelete}
customButtons={renderMergeButton()}
/>
</>
) : (
@ -291,6 +333,7 @@ export const Tag: React.FC = () => {
</div>
)}
{renderDeleteAlert()}
{renderMergeDialog()}
</div>
);
};

View file

@ -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<GQL.Tag, "id">;
mergeType: "from" | "into";
}
export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
show,
onClose,
tag,
mergeType,
}) => {
const [srcIds, setSrcIds] = useState<string[]>([]);
const [destId, setDestId] = useState<string | null>(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 (
<Modal
show={show}
header={title}
icon={mergeType === "from" ? "sign-in-alt" : "sign-out-alt"}
accept={{ text: "Merge", onClick: () => onMerge() }}
disabled={!canMerge()}
cancel={{
variant: "secondary",
onClick: () => onClose(),
}}
isRunning={running}
>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
{mergeType === "from" && (
<Form.Group controlId="source" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "dialogs.merge_tags.source" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
creatable={false}
onSelect={(items) => setSrcIds(items.map((item) => item.id))}
ids={srcIds}
excludeIds={tag?.id ? [tag.id] : []}
/>
</Col>
</Form.Group>
)}
{mergeType === "into" && (
<Form.Group controlId="destination" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.merge_tags.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti={false}
creatable={false}
onSelect={(items) => setDestId(items[0]?.id)}
ids={destId ? [destId] : undefined}
excludeIds={tag?.id ? [tag.id] : []}
/>
</Col>
</Form.Group>
)}
</div>
</div>
</Modal>
);
};

View file

@ -39,3 +39,7 @@
margin-bottom: 4rem;
}
}
#tag-merge-menu .dropdown-item {
align-items: center;
}

View file

@ -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 },

View file

@ -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}"