diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index fafd928f7..009c5114c 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -302,6 +302,8 @@ input StashBoxBatchTagInput { stash_box_endpoint: String "Fields to exclude when executing the tagging" exclude_fields: [String!] + "Collection fields to merge (add to existing) instead of overwriting when executing the tagging" + merge_fields: [String!] "Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false" refresh: Boolean! "If batch adding studios, should their parent studios also be created?" diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 76938e9ff..518b872f6 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -431,6 +431,8 @@ type StashBoxBatchTagInput struct { StashBoxEndpoint *string `json:"stash_box_endpoint"` // Fields to exclude when executing the tagging ExcludeFields []string `json:"exclude_fields"` + // Collection fields to merge (add to existing) instead of overwriting when executing the tagging + MergeFields []string `json:"merge_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` // If batch adding studios or tags, should their parent entities also be created? @@ -480,6 +482,7 @@ func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBat performer: performer, box: box, excludedFields: input.ExcludeFields, + mergeFields: input.MergeFields, }) } } @@ -500,6 +503,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu stashID: &stashID, box: box, excludedFields: input.ExcludeFields, + mergeFields: input.MergeFields, }) } } @@ -516,6 +520,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu name: &name, box: box, excludedFields: input.ExcludeFields, + mergeFields: input.MergeFields, }) } } @@ -546,6 +551,7 @@ func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatch performer: performer, box: box, excludedFields: input.ExcludeFields, + mergeFields: input.MergeFields, }) } return nil diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 264e7e96c..5193d9e68 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -27,6 +27,7 @@ type stashBoxBatchPerformerTagTask struct { stashID *string performer *models.Performer excludedFields []string + mergeFields []string } func (t *stashBoxBatchPerformerTagTask) getName() string { @@ -54,8 +55,13 @@ func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) { excluded[field] = true } + merge := map[string]bool{} + for _, field := range t.mergeFields { + merge[field] = true + } + if performer != nil { - t.processMatchedPerformer(ctx, performer, excluded) + t.processMatchedPerformer(ctx, performer, excluded, merge) } else { logger.Infof("No match found for %s", t.getName()) } @@ -157,7 +163,7 @@ func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Contex return mergedPerformer, nil } -func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) { +func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool, merge map[string]bool) { if t.performer != nil { storedID, _ := strconv.Atoi(*p.StoredID) @@ -176,7 +182,7 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont return err } - partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs) + partial := p.ToPartial(t.box.Endpoint, excluded, merge, existingStashIDs) // if we're setting the performer's aliases, and not the name, then filter out the name // from the aliases to avoid duplicates diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index d20fbd589..03865ac10 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -348,13 +348,17 @@ func (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]boo return nil, nil } -func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial { +func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, merge map[string]bool, existingStashIDs []StashID) PerformerPartial { ret := NewPerformerPartial() if p.Aliases != nil && !excluded["aliases"] { + mode := RelationshipUpdateModeSet + if merge["aliases"] { + mode = RelationshipUpdateModeAdd + } ret.Aliases = &UpdateStrings{ Values: stringslice.FromString(*p.Aliases, ","), - Mode: RelationshipUpdateModeSet, + Mode: mode, } } if p.Birthdate != nil && !excluded["birthdate"] { @@ -430,12 +434,17 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, ret.Tattoos = NewOptionalString(*p.Tattoos) } + urlMode := RelationshipUpdateModeSet + if merge["urls"] { + urlMode = RelationshipUpdateModeAdd + } + // if URLs are provided, only use those if len(p.URLs) > 0 { if !excluded["urls"] { ret.URLs = &UpdateStrings{ Values: p.URLs, - Mode: RelationshipUpdateModeSet, + Mode: urlMode, } } } else { @@ -453,7 +462,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, if len(urls) > 0 { ret.URLs = &UpdateStrings{ Values: urls, - Mode: RelationshipUpdateModeSet, + Mode: urlMode, } } } diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index 646dbf4c3..261c1ea44 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -86,5 +86,7 @@ export const PERFORMER_FIELDS = [ "details", ]; +export const PERFORMER_MERGEABLE_FIELDS = ["aliases", "urls"]; + export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"]; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 0e5ac2d9d..95990fbbd 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -1,5 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { + Button, + Card, + Col, + Collapse, + Form, + InputGroup, + ProgressBar, + Row, +} from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; @@ -19,10 +28,21 @@ import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import TaggerConfig, { ConfigButton } from "../TaggerConfig"; -import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; +import { + ITaggerConfig, + PERFORMER_FIELDS, + PERFORMER_MERGEABLE_FIELDS, +} from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { + faCheck, + faPlus, + faStar, + faTags, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "src/components/Shared/Icon"; import { mergeStashIDs } from "src/utils/stashbox"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { ExternalLink } from "src/components/Shared/ExternalLink"; @@ -36,11 +56,19 @@ type JobFragment = Pick< const CLASSNAME = "PerformerTagger"; +type FieldMode = "overwrite" | "merge" | "skip"; + interface IPerformerBatchUpdateModal { performers: GQL.PerformerDataFragment[]; isIdle: boolean; selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; + excludedFields: string[]; + onBatchUpdate: ( + queryAll: boolean, + refresh: boolean, + excludedFields: string[], + mergeFields: string[] + ) => void; close: () => void; } @@ -48,14 +76,36 @@ const PerformerBatchUpdateModal: React.FC = ({ performers, isIdle, selectedEndpoint, + excludedFields: initialExcludedFields, onBatchUpdate, close, }) => { const intl = useIntl(); const [queryAll, setQueryAll] = useState(false); - const [refresh, setRefresh] = useState(false); + const [showFieldSelect, setShowFieldSelect] = useState(false); + + const [fieldModes, setFieldModes] = useState>(() => + PERFORMER_FIELDS.reduce( + (acc, field) => ({ + ...acc, + [field]: initialExcludedFields.includes(field) ? "skip" : "overwrite", + }), + {} as Record + ) + ); + + const excludedFieldsList = useMemo( + () => PERFORMER_FIELDS.filter((f) => fieldModes[f] === "skip"), + [fieldModes] + ); + + const mergeFieldsList = useMemo( + () => PERFORMER_FIELDS.filter((f) => fieldModes[f] === "merge"), + [fieldModes] + ); + const { data: allPerformers } = GQL.useFindPerformersQuery({ variables: { performer_filter: { @@ -87,6 +137,52 @@ const PerformerBatchUpdateModal: React.FC = ({ ).length; }, [queryAll, refresh, performers, allPerformers, selectedEndpoint.endpoint]); + const cycleFieldMode = (field: string) => { + const isMergeable = PERFORMER_MERGEABLE_FIELDS.includes(field); + const current = fieldModes[field] ?? "overwrite"; + let next: FieldMode; + if (isMergeable) { + const cycle: FieldMode[] = ["overwrite", "merge", "skip"]; + next = cycle[(cycle.indexOf(current) + 1) % cycle.length]; + } else { + next = current === "overwrite" ? "skip" : "overwrite"; + } + setFieldModes({ ...fieldModes, [field]: next }); + }; + + const getFieldIcon = (mode: FieldMode) => { + switch (mode) { + case "overwrite": + return faCheck; + case "merge": + return faPlus; + case "skip": + return faTimes; + } + }; + + const getFieldClass = (mode: FieldMode) => { + switch (mode) { + case "overwrite": + return "text-success"; + case "merge": + return "text-info"; + case "skip": + return "text-muted"; + } + }; + + const getFieldLabel = (mode: FieldMode) => { + switch (mode) { + case "overwrite": + return intl.formatMessage({ id: "actions.overwrite" }); + case "merge": + return intl.formatMessage({ id: "actions.merge" }); + case "skip": + return intl.formatMessage({ id: "actions.skip" }); + } + }; + return ( = ({ text: intl.formatMessage({ id: "performer_tagger.update_performers", }), - onClick: () => onBatchUpdate(queryAll, refresh), + onClick: () => + onBatchUpdate(queryAll, refresh, excludedFieldsList, mergeFieldsList), }} cancel={{ text: intl.formatMessage({ id: "actions.cancel" }), @@ -165,6 +262,58 @@ const PerformerBatchUpdateModal: React.FC = ({ + + +
+ +
+
+ + + + + +
+ + {PERFORMER_FIELDS.map((field) => { + const mode = fieldModes[field] ?? "overwrite"; + return ( + + + + + + + ); + })} + +
+ {" "} + + {" | "} + {" "} + + {" | "} + {" "} + +
+
+
+
void; - onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; + onBatchUpdate: ( + ids: string[] | undefined, + refresh: boolean, + excludedFields: string[], + mergeFields: string[] + ) => void; } const PerformerTaggerList: React.FC = ({ @@ -333,8 +487,18 @@ const PerformerTaggerList: React.FC = ({ setShowBatchAdd(false); } - const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { - onBatchUpdate(!queryAll ? performers.map((p) => p.id) : undefined, refresh); + const handleBatchUpdate = ( + queryAll: boolean, + refresh: boolean, + excludedFields: string[], + mergeFields: string[] + ) => { + onBatchUpdate( + !queryAll ? performers.map((p) => p.id) : undefined, + refresh, + excludedFields, + mergeFields + ); setShowBatchUpdate(false); }; @@ -596,6 +760,7 @@ const PerformerTaggerList: React.FC = ({ isIdle={isIdle} selectedEndpoint={selectedEndpoint} performers={performers} + excludedFields={config.excludedPerformerFields ?? []} onBatchUpdate={handleBatchUpdate} /> )} @@ -701,13 +866,19 @@ export const PerformerTagger: React.FC = ({ performers }) => { } } - async function batchUpdate(ids: string[] | undefined, refresh: boolean) { + async function batchUpdate( + ids: string[] | undefined, + refresh: boolean, + excludedFields: string[], + mergeFields: string[] + ) { if (config && selectedEndpoint) { const ret = await mutateStashBoxBatchPerformerTag({ ids: ids, endpoint: selectedEndpointIndex, refresh, - exclude_fields: config.excludedPerformerFields ?? [], + exclude_fields: excludedFields, + merge_fields: mergeFields.length > 0 ? mergeFields : undefined, createParent: false, }); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4974c06ca..f3346bdea 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1373,8 +1373,11 @@ "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.", "batch_add_performers": "Batch Add Performers", "batch_update_performers": "Batch Update Performers", + "configure_fields": "Configure Fields", "current_page": "Current page", "failed_to_save_performer": "Failed to save performer \"{performer}\"", + "field_options": "Field Options", + "field_options_description": "Choose how each field is updated: overwrite replaces existing data, merge adds to it, skip leaves it unchanged.", "name_already_exists": "Name already exists", "network_error": "Network Error", "no_results_found": "No results found.",