From 4fe4da6c01447192385a828a29d022f8f85fcf26 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 16 Jun 2021 14:33:54 +1000 Subject: [PATCH] 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 --- graphql/documents/mutations/tag.graphql | 6 + graphql/schema/schema.graphql | 1 + graphql/schema/types/tag.graphql | 7 +- pkg/api/resolver_mutation_tag.go | 41 ++++++ pkg/models/mocks/PerformerReaderWriter.go | 18 +-- pkg/models/mocks/TagReaderWriter.go | 14 ++ pkg/models/tag.go | 1 + pkg/sqlite/setup_test.go | 11 ++ pkg/sqlite/tag.go | 61 ++++++++ pkg/sqlite/tag_test.go | 110 ++++++++++++++ .../src/components/Changelog/versions/v080.md | 1 + .../components/Shared/DetailsEditNavbar.tsx | 2 + ui/v2.5/src/components/Shared/Select.tsx | 12 +- .../src/components/Tags/TagDetails/Tag.tsx | 45 +++++- .../Tags/TagDetails/TagMergeDialog.tsx | 136 ++++++++++++++++++ ui/v2.5/src/components/Tags/styles.scss | 4 + ui/v2.5/src/core/StashService.ts | 5 + ui/v2.5/src/locales/en-GB.json | 7 + 18 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx 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}"