From daf93c1f5ff36d2a891eff6ebc1fd0f091351589 Mon Sep 17 00:00:00 2001 From: Wasylq Date: Mon, 4 May 2026 07:30:23 +0200 Subject: [PATCH 1/3] Configure fields when batch tagging performers Added a "Configure fields" button that allows you to select which fields get refreshed when you do "batch update performers" This field allows you to sync all data with exceptions, or merging your local edits with stashbox data --- graphql/schema/types/scraper.graphql | 2 + internal/manager/manager_tasks.go | 6 + internal/manager/task_stash_box_tag.go | 12 +- pkg/models/model_scraped_item.go | 17 +- ui/v2.5/src/components/Tagger/constants.ts | 2 + .../Tagger/performers/PerformerTagger.tsx | 188 ++++++++++++++++-- ui/v2.5/src/locales/en-GB.json | 3 + 7 files changed, 206 insertions(+), 24 deletions(-) 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..6b3c62f6c 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { Badge, 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 +19,11 @@ 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 +37,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 +57,38 @@ 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: { @@ -81,12 +114,58 @@ const PerformerBatchUpdateModal: React.FC = ({ return queryAll ? allPerformers?.findPerformers.count : filteredStashIDs.filter((s) => - // if refresh, then we filter out the performers without a stash id - // otherwise, we want untagged performers, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; + // if refresh, then we filter out the performers without a stash id + // otherwise, we want untagged performers, filtering out those with a stash id + refresh ? s.length > 0 : s.length === 0 + ).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 +245,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 +470,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); }; @@ -368,8 +515,8 @@ const PerformerTaggerList: React.FC = ({ details: message === "UNIQUE constraint failed: performers.name" ? intl.formatMessage({ - id: "performer_tagger.name_already_exists", - }) + id: "performer_tagger.name_already_exists", + }) : message, }, }); @@ -596,6 +743,7 @@ const PerformerTaggerList: React.FC = ({ isIdle={isIdle} selectedEndpoint={selectedEndpoint} performers={performers} + excludedFields={config.excludedPerformerFields ?? []} onBatchUpdate={handleBatchUpdate} /> )} @@ -701,13 +849,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.", From f2c450b83ace3950944ad7ad5e3b2e5b640bd2ac Mon Sep 17 00:00:00 2001 From: Wasylq Date: Mon, 4 May 2026 07:55:15 +0200 Subject: [PATCH 2/3] fix linting --- ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 6b3c62f6c..4b6b35d2b 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Badge, Button, Card, Col, Collapse, Form, InputGroup, ProgressBar, Row } 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"; From 223e3489a5b94f7a6da226620119f47d0c31c10f Mon Sep 17 00:00:00 2001 From: Wasylq Date: Mon, 4 May 2026 08:04:45 +0200 Subject: [PATCH 3/3] actually run pnpm run format not just remove the Badge --- .../Tagger/performers/PerformerTagger.tsx | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 4b6b35d2b..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, Col, Collapse, Form, InputGroup, ProgressBar, Row } 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,20 @@ import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import TaggerConfig, { ConfigButton } from "../TaggerConfig"; -import { ITaggerConfig, PERFORMER_FIELDS, PERFORMER_MERGEABLE_FIELDS } from "../constants"; +import { + ITaggerConfig, + PERFORMER_FIELDS, + PERFORMER_MERGEABLE_FIELDS, +} from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; -import { faCheck, faPlus, faStar, faTags, faTimes, } 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"; @@ -78,14 +97,12 @@ const PerformerBatchUpdateModal: React.FC = ({ ); const excludedFieldsList = useMemo( - () => - PERFORMER_FIELDS.filter((f) => fieldModes[f] === "skip"), + () => PERFORMER_FIELDS.filter((f) => fieldModes[f] === "skip"), [fieldModes] ); const mergeFieldsList = useMemo( - () => - PERFORMER_FIELDS.filter((f) => fieldModes[f] === "merge"), + () => PERFORMER_FIELDS.filter((f) => fieldModes[f] === "merge"), [fieldModes] ); @@ -114,10 +131,10 @@ const PerformerBatchUpdateModal: React.FC = ({ return queryAll ? allPerformers?.findPerformers.count : filteredStashIDs.filter((s) => - // if refresh, then we filter out the performers without a stash id - // otherwise, we want untagged performers, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; + // if refresh, then we filter out the performers without a stash id + // otherwise, we want untagged performers, filtering out those with a stash id + refresh ? s.length > 0 : s.length === 0 + ).length; }, [queryAll, refresh, performers, allPerformers, selectedEndpoint.endpoint]); const cycleFieldMode = (field: string) => { @@ -515,8 +532,8 @@ const PerformerTaggerList: React.FC = ({ details: message === "UNIQUE constraint failed: performers.name" ? intl.formatMessage({ - id: "performer_tagger.name_already_exists", - }) + id: "performer_tagger.name_already_exists", + }) : message, }, });