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
This commit is contained in:
Wasylq 2026-05-04 07:30:23 +02:00
parent 1ec5583931
commit daf93c1f5f
7 changed files with 206 additions and 24 deletions

View file

@ -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?"

View file

@ -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

View file

@ -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

View file

@ -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,
}
}
}

View file

@ -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"];

View file

@ -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<IPerformerBatchUpdateModal> = ({
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<Record<string, FieldMode>>(() =>
PERFORMER_FIELDS.reduce(
(acc, field) => ({
...acc,
[field]: initialExcludedFields.includes(field) ? "skip" : "overwrite",
}),
{} as Record<string, FieldMode>
)
);
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<IPerformerBatchUpdateModal> = ({
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 (
<ModalComponent
show
@ -98,7 +177,8 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
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<IPerformerBatchUpdateModal> = ({
<FormattedMessage id="performer_tagger.refreshing_will_update_the_data" />
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id="performer_tagger.field_options" />
</h6>
</Form.Label>
<Form.Text className="mb-2 d-block">
<FormattedMessage id="performer_tagger.field_options_description" />
</Form.Text>
<Button
onClick={() => setShowFieldSelect(!showFieldSelect)}
className="mt-1"
size="sm"
>
<FormattedMessage id="performer_tagger.configure_fields" />
</Button>
<Collapse in={showFieldSelect}>
<div className="mt-2">
<Row>
{PERFORMER_FIELDS.map((field) => {
const mode = fieldModes[field] ?? "overwrite";
return (
<Col xs={6} className="mb-1" key={field}>
<Button
onClick={() => cycleFieldMode(field)}
variant="secondary"
size="sm"
className={getFieldClass(mode)}
title={getFieldLabel(mode)}
>
<Icon icon={getFieldIcon(mode)} />
</Button>
<span className="ml-2">
<FormattedMessage id={field} />
</span>
</Col>
);
})}
</Row>
<div className="mt-2 small text-muted">
<Icon icon={faCheck} className="text-success" />{" "}
<FormattedMessage id="actions.overwrite" />
{" | "}
<Icon icon={faPlus} className="text-info" />{" "}
<FormattedMessage id="actions.merge" />
{" | "}
<Icon icon={faTimes} className="text-muted" />{" "}
<FormattedMessage id="actions.skip" />
</div>
</div>
</Collapse>
</Form.Group>
<b>
<FormattedMessage
id="performer_tagger.number_of_performers_will_be_processed"
@ -240,7 +372,12 @@ interface IPerformerTaggerListProps {
isIdle: boolean;
config: ITaggerConfig;
onBatchAdd: (performerInput: string) => void;
onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void;
onBatchUpdate: (
ids: string[] | undefined,
refresh: boolean,
excludedFields: string[],
mergeFields: string[]
) => void;
}
const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
@ -333,8 +470,18 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
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<IPerformerTaggerListProps> = ({
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<IPerformerTaggerListProps> = ({
isIdle={isIdle}
selectedEndpoint={selectedEndpoint}
performers={performers}
excludedFields={config.excludedPerformerFields ?? []}
onBatchUpdate={handleBatchUpdate}
/>
)}
@ -701,13 +849,19 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ 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,
});

View file

@ -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.",