mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge 223e3489a5 into 46f72e5574
This commit is contained in:
commit
60cb9b9a35
7 changed files with 217 additions and 18 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,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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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