mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
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:
parent
1ec5583931
commit
daf93c1f5f
7 changed files with 206 additions and 24 deletions
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue