mirror of
https://github.com/stashapp/stash.git
synced 2026-02-07 16:05:47 +01:00
Merge ff6996680c into 8dec195c2d
This commit is contained in:
commit
dbaafe2484
11 changed files with 560 additions and 123 deletions
|
|
@ -78,6 +78,8 @@ type FindTagsResultType {
|
|||
input TagsMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: TagUpdateInput
|
||||
}
|
||||
|
||||
input BulkTagUpdateInput {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
|
|
@ -103,17 +102,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
|||
return r.getTag(ctx, newTag.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {
|
||||
updatedTag := models.NewTagPartial()
|
||||
|
||||
updatedTag.Name = translator.optionalString(input.Name, "name")
|
||||
|
|
@ -132,6 +121,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
}
|
||||
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
|
||||
|
||||
var err error
|
||||
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||
|
|
@ -149,6 +139,25 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)
|
||||
}
|
||||
|
||||
return &updatedTag, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
updatedTag, err := tagPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
|
|
@ -185,11 +194,11 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
}
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||
if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
|
||||
t, err = qb.UpdatePartial(ctx, tagID, *updatedTag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -337,6 +346,31 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
var values *models.TagPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = tagPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewTagPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var t *models.Tag
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
|
@ -351,28 +385,22 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
|||
return fmt.Errorf("tag with id %d not found", destination)
|
||||
}
|
||||
|
||||
parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = qb.Merge(ctx, source, destination); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = qb.UpdateParentTags(ctx, destination, parents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = qb.UpdateChildTags(ctx, destination, children)
|
||||
if err != nil {
|
||||
if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
|
||||
if err != nil {
|
||||
logger.Errorf("Error merging tag: %s", err)
|
||||
return err
|
||||
if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {
|
||||
return fmt.Errorf("updating tag: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destination, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -220,49 +220,3 @@ func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs,
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MergeHierarchy(ctx context.Context, destination int, sources []int, qb RelationshipFinder) ([]int, []int, error) {
|
||||
var mergedParents, mergedChildren []int
|
||||
allIds := append([]int{destination}, sources...)
|
||||
|
||||
addTo := func(mergedItems []int, tagIDs []int) []int {
|
||||
Tags:
|
||||
for _, tagID := range tagIDs {
|
||||
// Ignore tags which are already set
|
||||
for _, existingItem := range mergedItems {
|
||||
if tagID == existingItem {
|
||||
continue Tags
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored)
|
||||
for _, id := range allIds {
|
||||
if tagID == id {
|
||||
continue Tags
|
||||
}
|
||||
}
|
||||
|
||||
mergedItems = append(mergedItems, tagID)
|
||||
}
|
||||
|
||||
return mergedItems
|
||||
}
|
||||
|
||||
for _, id := range allIds {
|
||||
parents, err := qb.GetParentIDs(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mergedParents = addTo(mergedParents, parents)
|
||||
|
||||
children, err := qb.GetChildIDs(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mergedChildren = addTo(mergedChildren, children)
|
||||
}
|
||||
|
||||
return mergedParents, mergedChildren, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ fragment TagData on Tag {
|
|||
children {
|
||||
...SlimTagData
|
||||
}
|
||||
|
||||
custom_fields
|
||||
}
|
||||
|
||||
fragment SelectTagData on Tag {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,14 @@ mutation BulkTagUpdate($input: BulkTagUpdateInput!) {
|
|||
}
|
||||
}
|
||||
|
||||
mutation TagsMerge($source: [ID!]!, $destination: ID!) {
|
||||
tagsMerge(input: { source: $source, destination: $destination }) {
|
||||
mutation TagsMerge(
|
||||
$source: [ID!]!
|
||||
$destination: ID!
|
||||
$values: TagUpdateInput
|
||||
) {
|
||||
tagsMerge(
|
||||
input: { source: $source, destination: $destination, values: $values }
|
||||
) {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
query FindTags($filter: FindFilterType, $tag_filter: TagFilterType) {
|
||||
findTags(filter: $filter, tag_filter: $tag_filter) {
|
||||
query FindTags(
|
||||
$filter: FindFilterType
|
||||
$tag_filter: TagFilterType
|
||||
$ids: [ID!]
|
||||
) {
|
||||
findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) {
|
||||
count
|
||||
tags {
|
||||
...TagData
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useToast } from "src/hooks/Toast";
|
|||
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
import {
|
||||
ScrapedCustomFieldRows,
|
||||
ScrapedImageRow,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedStringListRow,
|
||||
|
|
@ -27,9 +28,9 @@ import {
|
|||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import {
|
||||
CustomFieldScrapeResults,
|
||||
ObjectListScrapeResult,
|
||||
ScrapeResult,
|
||||
ZeroableScrapeResult,
|
||||
hasScrapedValues,
|
||||
} from "../Shared/ScrapeDialog/scrapeResult";
|
||||
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
|
|
@ -40,39 +41,6 @@ import {
|
|||
import { PerformerSelect } from "./PerformerSelect";
|
||||
import { uniq } from "lodash-es";
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
|
||||
|
||||
// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support
|
||||
// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same
|
||||
// for consistency.
|
||||
function renderScrapedCustomFieldRows(
|
||||
results: CustomFieldScrapeResults,
|
||||
onChange: (newCustomFields: CustomFieldScrapeResults) => void
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{Array.from(results.entries()).map(([field, result]) => {
|
||||
const fieldName = `custom_${field}`;
|
||||
return (
|
||||
<ScrapedInputGroupRow
|
||||
className="custom-field"
|
||||
title={field}
|
||||
field={fieldName}
|
||||
key={fieldName}
|
||||
result={result}
|
||||
onChange={(newResult) => {
|
||||
const newResults = new Map(results);
|
||||
newResults.set(field, newResult);
|
||||
onChange(newResults);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MergeOptions = {
|
||||
values: GQL.PerformerUpdateInput;
|
||||
};
|
||||
|
|
@ -604,10 +572,12 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
|||
result={image}
|
||||
onChange={(value) => setImage(value)}
|
||||
/>
|
||||
{hasCustomFieldValues &&
|
||||
renderScrapedCustomFieldRows(customFields, (newCustomFields) =>
|
||||
setCustomFields(newCustomFields)
|
||||
)}
|
||||
{hasCustomFieldValues && (
|
||||
<ScrapedCustomFieldRows
|
||||
results={customFields}
|
||||
onChange={(newCustomFields) => setCustomFields(newCustomFields)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { getCountryByISO } from "src/utils/country";
|
|||
import { CountrySelect } from "../CountrySelect";
|
||||
import { StringListInput } from "../StringListInput";
|
||||
import { ImageSelector } from "../ImageSelector";
|
||||
import { ScrapeResult } from "./scrapeResult";
|
||||
import { CustomFieldScrapeResults, ScrapeResult } from "./scrapeResult";
|
||||
import { ScrapeDialogContext } from "./ScrapeDialog";
|
||||
|
||||
function renderButtonIcon(selected: boolean) {
|
||||
|
|
@ -431,3 +431,30 @@ export const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({
|
|||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ScrapedCustomFieldRows: React.FC<{
|
||||
results: CustomFieldScrapeResults;
|
||||
onChange: (newCustomFields: CustomFieldScrapeResults) => void;
|
||||
}> = ({ results, onChange }) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from(results.entries()).map(([field, result]) => {
|
||||
const fieldName = `custom_${field}`;
|
||||
return (
|
||||
<ScrapedInputGroupRow
|
||||
className="custom-field"
|
||||
title={field}
|
||||
field={fieldName}
|
||||
key={fieldName}
|
||||
result={result}
|
||||
onChange={(newResult) => {
|
||||
const newResults = new Map(results);
|
||||
newResults.set(field, newResult);
|
||||
onChange(newResults);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import lodashIsEqual from "lodash-es/isEqual";
|
|||
import clone from "lodash-es/clone";
|
||||
import { IHasStoredID } from "src/utils/data";
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
export type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
|
||||
|
||||
export class ScrapeResult<T> {
|
||||
public newValue?: T;
|
||||
public originalValue?: T;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,412 @@
|
|||
import { Button, Form, Col, Row } from "react-bootstrap";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { useTagsMerge } from "src/core/StashService";
|
||||
import { useIntl } from "react-intl";
|
||||
import { queryFindTagsByID, useTagsMerge } from "src/core/StashService";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Tag, TagSelect } from "./TagSelect";
|
||||
import {
|
||||
CustomFieldScrapeResults,
|
||||
hasScrapedValues,
|
||||
ObjectListScrapeResult,
|
||||
ScrapeResult,
|
||||
} from "../Shared/ScrapeDialog/scrapeResult";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { uniq } from "lodash-es";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import {
|
||||
ScrapedCustomFieldRows,
|
||||
ScrapeDialogRow,
|
||||
ScrapedImageRow,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedStringListRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
|
||||
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import { StringListSelect } from "../Shared/Select";
|
||||
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
|
||||
interface IStashIDsField {
|
||||
values: GQL.StashId[];
|
||||
}
|
||||
|
||||
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
|
||||
return <StringListSelect value={values.map((v) => v.stash_id)} />;
|
||||
};
|
||||
|
||||
interface ITagMergeDetailsProps {
|
||||
sources: GQL.TagDataFragment[];
|
||||
dest: GQL.TagDataFragment;
|
||||
onClose: (values?: GQL.TagUpdateInput) => void;
|
||||
}
|
||||
|
||||
const TagMergeDetails: React.FC<ITagMergeDetailsProps> = ({
|
||||
sources,
|
||||
dest,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filterCandidates = useCallback(
|
||||
(t: { stored_id: string }) =>
|
||||
t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id),
|
||||
[dest.id, sources]
|
||||
);
|
||||
|
||||
const [name, setName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.name)
|
||||
);
|
||||
const [sortName, setSortName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.sort_name)
|
||||
);
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(dest.aliases)
|
||||
);
|
||||
const [description, setDescription] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.description)
|
||||
);
|
||||
const [parentTags, setParentTags] = useState<
|
||||
ObjectListScrapeResult<GQL.ScrapedTag>
|
||||
>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
dest.parents.map(idToStoredID).filter(filterCandidates)
|
||||
)
|
||||
)
|
||||
);
|
||||
const [childTags, setChildTags] = useState<
|
||||
ObjectListScrapeResult<GQL.ScrapedTag>
|
||||
>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
dest.children.map(idToStoredID).filter(filterCandidates)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.image_path)
|
||||
);
|
||||
|
||||
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
function idToStoredID(o: { id: string; name: string }) {
|
||||
return {
|
||||
stored_id: o.id,
|
||||
name: o.name,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate the values for everything
|
||||
// uses the first set value for single value fields, and combines all
|
||||
useEffect(() => {
|
||||
async function loadImages() {
|
||||
const src = sources.find((s) => s.image_path);
|
||||
if (!dest.image_path || !src) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const destData = await ImageUtils.imageToDataURL(dest.image_path);
|
||||
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
|
||||
|
||||
// keep destination image by default
|
||||
const useNewValue = false;
|
||||
setImage(new ScrapeResult(destData, srcData, useNewValue));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// append dest to all so that if dest has stash_ids with the same
|
||||
// endpoint, then it will be excluded first
|
||||
const all = sources.concat(dest);
|
||||
|
||||
setName(
|
||||
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
|
||||
);
|
||||
setSortName(
|
||||
new ScrapeResult(
|
||||
dest.sort_name,
|
||||
sources.find((s) => s.sort_name)?.sort_name,
|
||||
!dest.sort_name
|
||||
)
|
||||
);
|
||||
|
||||
setDescription(
|
||||
new ScrapeResult(
|
||||
dest.description,
|
||||
sources.find((s) => s.description)?.description,
|
||||
!dest.description
|
||||
)
|
||||
);
|
||||
|
||||
// default alias list should be the existing aliases, plus the names of all sources,
|
||||
// plus all source aliases, deduplicated
|
||||
const allAliases = uniq(
|
||||
dest.aliases.concat(
|
||||
sources.map((s) => s.name),
|
||||
sources.flatMap((s) => s.aliases)
|
||||
)
|
||||
);
|
||||
setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length));
|
||||
|
||||
// default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated
|
||||
const allParentTags = uniq(all.flatMap((s) => s.parents))
|
||||
.map(idToStoredID)
|
||||
.filter(filterCandidates); // exclude self and sources
|
||||
|
||||
setParentTags(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(dest.parents.map(idToStoredID)),
|
||||
sortStoredIdObjects(allParentTags),
|
||||
!!allParentTags.length
|
||||
)
|
||||
);
|
||||
|
||||
const allChildTags = uniq(all.flatMap((s) => s.children))
|
||||
.map(idToStoredID)
|
||||
.filter(filterCandidates); // exclude self and sources
|
||||
|
||||
setChildTags(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
dest.children.map(idToStoredID).filter(filterCandidates)
|
||||
),
|
||||
sortStoredIdObjects(allChildTags),
|
||||
!!allChildTags.length
|
||||
)
|
||||
);
|
||||
|
||||
setStashIDs(
|
||||
new ScrapeResult(
|
||||
dest.stash_ids,
|
||||
all
|
||||
.map((s) => s.stash_ids)
|
||||
.flat()
|
||||
.filter((s, index, a) => {
|
||||
// remove entries with duplicate endpoints
|
||||
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
setImage(
|
||||
new ScrapeResult(
|
||||
dest.image_path,
|
||||
sources.find((s) => s.image_path)?.image_path,
|
||||
!dest.image_path
|
||||
)
|
||||
);
|
||||
|
||||
const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));
|
||||
|
||||
for (const s of sources) {
|
||||
for (const n of Object.keys(s.custom_fields)) {
|
||||
customFieldNames.add(n);
|
||||
}
|
||||
}
|
||||
|
||||
setCustomFields(
|
||||
new Map(
|
||||
Array.from(customFieldNames)
|
||||
.sort()
|
||||
.map((field) => {
|
||||
return [
|
||||
field,
|
||||
new ScrapeResult(
|
||||
dest.custom_fields?.[field],
|
||||
sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
|
||||
field
|
||||
],
|
||||
dest.custom_fields?.[field] === undefined
|
||||
),
|
||||
];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
loadImages();
|
||||
}, [sources, dest, filterCandidates]);
|
||||
|
||||
const hasCustomFieldValues = useMemo(() => {
|
||||
return hasScrapedValues(Array.from(customFields.values()));
|
||||
}, [customFields]);
|
||||
|
||||
// ensure this is updated if fields are changed
|
||||
const hasValues = useMemo(() => {
|
||||
return (
|
||||
hasCustomFieldValues ||
|
||||
hasScrapedValues([
|
||||
name,
|
||||
sortName,
|
||||
aliases,
|
||||
description,
|
||||
parentTags,
|
||||
childTags,
|
||||
stashIDs,
|
||||
image,
|
||||
])
|
||||
);
|
||||
}, [
|
||||
name,
|
||||
sortName,
|
||||
aliases,
|
||||
description,
|
||||
parentTags,
|
||||
childTags,
|
||||
stashIDs,
|
||||
image,
|
||||
hasCustomFieldValues,
|
||||
]);
|
||||
|
||||
function renderScrapeRows() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValues) {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage id="dialogs.merge.empty_results" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrapedInputGroupRow
|
||||
field="name"
|
||||
title={intl.formatMessage({ id: "name" })}
|
||||
result={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="sort_name"
|
||||
title={intl.formatMessage({ id: "sort_name" })}
|
||||
result={sortName}
|
||||
onChange={(value) => setSortName(value)}
|
||||
/>
|
||||
<ScrapedStringListRow
|
||||
field="aliases"
|
||||
title={intl.formatMessage({ id: "aliases" })}
|
||||
result={aliases}
|
||||
onChange={(value) => setAliases(value)}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
field="parent_tags"
|
||||
title={intl.formatMessage({ id: "parent_tags" })}
|
||||
result={parentTags}
|
||||
onChange={(value) => setParentTags(value)}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
field="child_tags"
|
||||
title={intl.formatMessage({ id: "sub_tags" })}
|
||||
result={childTags}
|
||||
onChange={(value) => setChildTags(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="description"
|
||||
title={intl.formatMessage({ id: "description" })}
|
||||
result={description}
|
||||
onChange={(value) => setDescription(value)}
|
||||
/>
|
||||
<ScrapeDialogRow
|
||||
field="stash_ids"
|
||||
title={intl.formatMessage({ id: "stash_id" })}
|
||||
result={stashIDs}
|
||||
originalField={
|
||||
<StashIDsField values={stashIDs?.originalValue ?? []} />
|
||||
}
|
||||
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
|
||||
onChange={(value) => setStashIDs(value)}
|
||||
/>
|
||||
<ScrapedImageRow
|
||||
field="image"
|
||||
title={intl.formatMessage({ id: "tag_image" })}
|
||||
className="performer-image"
|
||||
result={image}
|
||||
onChange={(value) => setImage(value)}
|
||||
/>
|
||||
{hasCustomFieldValues && (
|
||||
<ScrapedCustomFieldRows
|
||||
results={customFields}
|
||||
onChange={(newCustomFields) => setCustomFields(newCustomFields)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createValues(): GQL.TagUpdateInput {
|
||||
// only set the cover image if it's different from the existing cover image
|
||||
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
|
||||
|
||||
return {
|
||||
id: dest.id,
|
||||
name: name.getNewValue(),
|
||||
sort_name: sortName.getNewValue(),
|
||||
aliases: aliases
|
||||
.getNewValue()
|
||||
?.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!),
|
||||
child_ids: childTags.getNewValue()?.map((t) => t.stored_id!),
|
||||
description: description.getNewValue(),
|
||||
stash_ids: stashIDs.getNewValue(),
|
||||
image: coverImage,
|
||||
custom_fields: {
|
||||
partial: Object.fromEntries(
|
||||
Array.from(customFields.entries()).flatMap(([field, v]) =>
|
||||
v.useNewValue ? [[field, v.getNewValue()]] : []
|
||||
)
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dialogTitle = intl.formatMessage({
|
||||
id: "actions.merge",
|
||||
});
|
||||
|
||||
const destinationLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.destination" });
|
||||
const sourceLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.source" });
|
||||
|
||||
return (
|
||||
<ScrapeDialog
|
||||
className="tag-merge-dialog"
|
||||
title={dialogTitle}
|
||||
existingLabel={destinationLabel}
|
||||
scrapedLabel={sourceLabel}
|
||||
onClose={(apply) => {
|
||||
if (!apply) {
|
||||
onClose();
|
||||
} else {
|
||||
onClose(createValues());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderScrapeRows()}
|
||||
</ScrapeDialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITagMergeModalProps {
|
||||
show: boolean;
|
||||
|
|
@ -23,6 +422,11 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
|
|||
const [src, setSrc] = useState<Tag[]>([]);
|
||||
const [dest, setDest] = useState<Tag | null>(null);
|
||||
|
||||
const [loadedSources, setLoadedSources] = useState<GQL.TagDataFragment[]>([]);
|
||||
const [loadedDest, setLoadedDest] = useState<GQL.TagDataFragment>();
|
||||
|
||||
const [secondStep, setSecondStep] = useState(false);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const [mergeTags] = useTagsMerge();
|
||||
|
|
@ -41,7 +445,18 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
|
|||
}
|
||||
}, [tags]);
|
||||
|
||||
async function onMerge() {
|
||||
async function loadTags() {
|
||||
const tagIDs = src.map((s) => s.id);
|
||||
tagIDs.push(dest!.id);
|
||||
const query = await queryFindTagsByID(tagIDs);
|
||||
const { tags: loadedTags } = query.data.findTags;
|
||||
|
||||
setLoadedDest(loadedTags.find((s) => s.id === dest!.id));
|
||||
setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id));
|
||||
setSecondStep(true);
|
||||
}
|
||||
|
||||
async function onMerge(values: GQL.TagUpdateInput) {
|
||||
if (!dest) return;
|
||||
|
||||
const source = src.map((s) => s.id);
|
||||
|
|
@ -53,6 +468,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
|
|||
variables: {
|
||||
source,
|
||||
destination,
|
||||
values,
|
||||
},
|
||||
});
|
||||
if (result.data?.tagsMerge) {
|
||||
|
|
@ -78,6 +494,23 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (secondStep && dest) {
|
||||
return (
|
||||
<TagMergeDetails
|
||||
sources={loadedSources}
|
||||
dest={loadedDest!}
|
||||
onClose={(values) => {
|
||||
setSecondStep(false);
|
||||
if (values) {
|
||||
onMerge(values);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
show={show}
|
||||
|
|
@ -85,7 +518,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
|
|||
icon={faSignInAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.merge" }),
|
||||
onClick: () => onMerge(),
|
||||
onClick: () => loadTags(),
|
||||
}}
|
||||
disabled={!canMerge()}
|
||||
cancel={{
|
||||
|
|
|
|||
|
|
@ -472,6 +472,14 @@ export const queryFindTagsForList = (filter: ListFilterModel) =>
|
|||
},
|
||||
});
|
||||
|
||||
export const queryFindTagsByID = (tagIDs: string[]) =>
|
||||
client.query<GQL.FindTagsQuery>({
|
||||
query: GQL.FindTagsDocument,
|
||||
variables: {
|
||||
ids: tagIDs,
|
||||
},
|
||||
});
|
||||
|
||||
export const queryFindTagsByIDForSelect = (tagIDs: string[]) =>
|
||||
client.query<GQL.FindTagsForSelectQuery>({
|
||||
query: GQL.FindTagsForSelectDocument,
|
||||
|
|
|
|||
Loading…
Reference in a new issue