mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
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:
parent
45f4a5ba81
commit
4fe4da6c01
18 changed files with 468 additions and 14 deletions
|
|
@ -17,3 +17,9 @@ mutation TagUpdate($input: TagUpdateInput!) {
|
|||
...TagData
|
||||
}
|
||||
}
|
||||
|
||||
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
|
||||
tagsMerge(input: { source: $source, destination: $destination }) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -37,4 +37,9 @@ input TagDestroyInput {
|
|||
type FindTagsResultType {
|
||||
count: Int!
|
||||
tags: [Tag!]!
|
||||
}
|
||||
}
|
||||
|
||||
input TagsMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
136
ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx
Normal file
136
ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -39,3 +39,7 @@
|
|||
margin-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
#tag-merge-menu .dropdown-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue