This commit is contained in:
Wasylq 2026-05-05 06:42:35 +02:00 committed by GitHub
commit 60cb9b9a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 18 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,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<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: {
@ -87,6 +137,52 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
).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 +194,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 +262,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 +389,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 +487,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);
};
@ -596,6 +760,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
isIdle={isIdle}
selectedEndpoint={selectedEndpoint}
performers={performers}
excludedFields={config.excludedPerformerFields ?? []}
onBatchUpdate={handleBatchUpdate}
/>
)}
@ -701,13 +866,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.",