Add parent tag hierarchy support to tag tagger (#6620)

This commit is contained in:
Gykes 2026-03-15 17:34:57 -07:00 committed by GitHub
parent b8bd8953f7
commit b4fab0ac48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 867 additions and 484 deletions

View file

@ -73,6 +73,7 @@ type ScrapedTag {
name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable"
remote_site_id: String
}

View file

@ -31,6 +31,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment MeasurementsFragment on Measurements {

View file

@ -431,7 +431,7 @@ type StashBoxBatchTagInput struct {
ExcludeFields []string `json:"exclude_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, should their parent studios also be created?
// If batch adding studios or tags, should their parent entities also be created?
CreateParent bool `json:"createParent"`
// IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored.
@ -749,6 +749,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@ -769,6 +770,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@ -780,6 +782,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@ -806,6 +809,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp
for _, t := range tags {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})

View file

@ -541,6 +541,7 @@ type stashBoxBatchTagTagTask struct {
name *string
stashID *string
tag *models.Tag
createParent bool
excludedFields []string
}
@ -630,7 +631,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
result := results[0]
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
}); err != nil {
return nil, err
}
@ -638,6 +639,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
return result, nil
}
func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {
if parent.StoredID == nil {
// Create new parent tag
newParentTag := parent.ToTag(t.box.Endpoint, excluded)
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {
return err
}
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {
return err
}
storedID := strconv.Itoa(newParentTag.ID)
parent.StoredID = &storedID
return nil
})
if err != nil {
logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err)
} else {
logger.Infof("Created parent tag %s", parent.Name)
}
return err
}
// Parent already exists — nothing to update for categories
return nil
}
func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {
// Determine the tag ID to update — either from the task's tag or from the
// StoredID set by match.ScrapedTag (when batch adding by name and the tag
@ -649,6 +683,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode
tagID, _ = strconv.Atoi(*s.StoredID)
}
if s.Parent != nil && t.createParent {
if err := t.processParentTag(ctx, s.Parent, excluded); err != nil {
return
}
}
if tagID > 0 {
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {

View file

@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
return
}
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
return err
}
if s.Parent == nil {
return nil
}
// Match parent by name only (categories don't have StashDB tag IDs)
return ScrapedTag(ctx, qb, s.Parent, "")
}
// ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found.
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {

View file

@ -471,11 +471,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
type ScrapedTag struct {
// Set if tag matched
StoredID *string `json:"stored_id"`
Name string `json:"name"`
Description *string `json:"description"`
AliasList []string `json:"alias_list"`
RemoteSiteID *string `json:"remote_site_id"`
StoredID *string `json:"stored_id"`
Name string `json:"name"`
Description *string `json:"description"`
AliasList []string `json:"alias_list"`
RemoteSiteID *string `json:"remote_site_id"`
Parent *ScrapedTag `json:"parent"`
}
func (ScrapedTag) IsScrapedContent() {}
@ -496,6 +497,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
ret.Aliases = NewRelatedStrings(t.AliasList)
}
if t.Parent != nil && t.Parent.StoredID != nil {
parentID, err := strconv.Atoi(*t.Parent.StoredID)
if err == nil && parentID > 0 {
ret.ParentIDs = NewRelatedIDs([]int{parentID})
}
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
@ -527,6 +535,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st
}
}
if t.Parent != nil && t.Parent.StoredID != nil {
parentID, err := strconv.Atoi(*t.Parent.StoredID)
if err == nil && parentID > 0 {
ret.ParentIDs = &UpdateIDs{
IDs: []int{parentID},
Mode: RelationshipUpdateModeAdd,
}
}
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,

View file

@ -128,10 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment {
}
type TagFragment struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
Description *string "json:\"description,omitempty\" graphql:\"description\""
Aliases []string "json:\"aliases\" graphql:\"aliases\""
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
Description *string "json:\"description,omitempty\" graphql:\"description\""
Aliases []string "json:\"aliases\" graphql:\"aliases\""
Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\""
}
func (t *TagFragment) GetName() string {
@ -158,6 +159,12 @@ func (t *TagFragment) GetAliases() []string {
}
return t.Aliases
}
func (t *TagFragment) GetCategory() *TagFragment_Category {
if t == nil {
t = &TagFragment{}
}
return t.Category
}
type MeasurementsFragment struct {
BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\""
@ -530,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string {
return t.Name
}
type TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *TagFragment_Category) GetDescription() *string {
if t == nil {
t = &TagFragment_Category{}
}
return t.Description
}
func (t *TagFragment_Category) GetID() string {
if t == nil {
t = &TagFragment_Category{}
}
return t.ID
}
func (t *TagFragment_Category) GetName() string {
if t == nil {
t = &TagFragment_Category{}
}
return t.Name
}
type SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
@ -548,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
return t.Name
}
type SceneFragment_Tags_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &SceneFragment_Tags_TagFragment_Category{}
}
return t.Description
}
func (t *SceneFragment_Tags_TagFragment_Category) GetID() string {
if t == nil {
t = &SceneFragment_Tags_TagFragment_Category{}
}
return t.ID
}
func (t *SceneFragment_Tags_TagFragment_Category) GetName() string {
if t == nil {
t = &SceneFragment_Tags_TagFragment_Category{}
}
return t.Name
}
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
@ -566,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme
return t.Name
}
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
}
return t.Description
}
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string {
if t == nil {
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
}
return t.ID
}
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string {
if t == nil {
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}
}
return t.Name
}
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
@ -584,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get
return t.Name
}
type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.Description
}
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
if t == nil {
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.ID
}
func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
if t == nil {
t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.Name
}
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
@ -602,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get
return t.Name
}
type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.Description
}
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string {
if t == nil {
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.ID
}
func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string {
if t == nil {
t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}
}
return t.Name
}
type FindStudio_FindStudio_StudioFragment_Parent struct {
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
@ -620,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
return t.Name
}
type FindTag_FindTag_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &FindTag_FindTag_TagFragment_Category{}
}
return t.Description
}
func (t *FindTag_FindTag_TagFragment_Category) GetID() string {
if t == nil {
t = &FindTag_FindTag_TagFragment_Category{}
}
return t.ID
}
func (t *FindTag_FindTag_TagFragment_Category) GetName() string {
if t == nil {
t = &FindTag_FindTag_TagFragment_Category{}
}
return t.Name
}
type QueryTags_QueryTags_Tags_TagFragment_Category struct {
Description *string "json:\"description,omitempty\" graphql:\"description\""
ID string "json:\"id\" graphql:\"id\""
Name string "json:\"name\" graphql:\"name\""
}
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string {
if t == nil {
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
}
return t.Description
}
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string {
if t == nil {
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
}
return t.ID
}
func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string {
if t == nil {
t = &QueryTags_QueryTags_Tags_TagFragment_Category{}
}
return t.Name
}
type QueryTags_QueryTags struct {
Count int "json:\"count\" graphql:\"count\""
Tags []*TagFragment "json:\"tags\" graphql:\"tags\""
@ -865,6 +1047,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -1003,6 +1190,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -1299,6 +1491,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -1435,6 +1632,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
`
@ -1469,6 +1671,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
`

View file

@ -72,5 +72,12 @@ func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {
ret.AliasList = t.Aliases
}
if t.Category != nil {
ret.Parent = &models.ScrapedTag{
Name: t.Category.Name,
Description: t.Category.Description,
}
}
return ret
}

View file

@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag {
name
description
alias_list
parent {
stored_id
name
description
}
remote_site_id
}

View file

@ -0,0 +1,242 @@
import React, { useMemo, useRef, useState } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { ModalComponent } from "src/components/Shared/Modal";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
interface IEntityWithStashIDs {
stash_ids: { endpoint: string }[];
}
interface IBatchUpdateModalProps {
entities: IEntityWithStashIDs[];
isIdle: boolean;
selectedEndpoint: { endpoint: string; index: number };
allCount: number | undefined;
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
onRefreshChange?: (refresh: boolean) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
localePrefix: string;
entityName: string;
countVariableName: string;
}
export const BatchUpdateModal: React.FC<IBatchUpdateModalProps> = ({
entities,
isIdle,
selectedEndpoint,
allCount,
onBatchUpdate,
onRefreshChange,
batchAddParents,
setBatchAddParents,
close,
localePrefix,
entityName,
countVariableName,
}) => {
const intl = useIntl();
const [queryAll, setQueryAll] = useState(false);
const [refresh, setRefreshState] = useState(false);
const setRefresh = (value: boolean) => {
setRefreshState(value);
onRefreshChange?.(value);
};
const entityCount = useMemo(() => {
const filteredStashIDs = entities.map((e) =>
e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
);
return queryAll
? allCount
: filteredStashIDs.filter((s) =>
refresh ? s.length > 0 : s.length === 0
).length;
}, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]);
return (
<ModalComponent
show
icon={faTags}
header={intl.formatMessage({
id: `${localePrefix}.update_${entityName}s`,
})}
accept={{
text: intl.formatMessage({
id: `${localePrefix}.update_${entityName}s`,
}),
onClick: () => onBatchUpdate(queryAll, refresh),
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id={`${localePrefix}.${entityName}_selection`} />
</h6>
</Form.Label>
<Form.Check
id="query-page"
type="radio"
name={`${entityName}-query`}
label={<FormattedMessage id={`${localePrefix}.current_page`} />}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
id="query-all"
type="radio"
name={`${entityName}-query`}
label={intl.formatMessage({
id: `${localePrefix}.query_all_${entityName}s_in_the_database`,
})}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id={`${localePrefix}.tag_status`} />
</h6>
</Form.Label>
<Form.Check
id={`untagged-${entityName}s`}
type="radio"
name={`${entityName}-refresh`}
label={intl.formatMessage({
id: `${localePrefix}.untagged_${entityName}s`,
})}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.updating_untagged_${entityName}s_description`}
/>
</Form.Text>
<Form.Check
id={`tagged-${entityName}s`}
type="radio"
name={`${entityName}-refresh`}
label={intl.formatMessage({
id: `${localePrefix}.refresh_tagged_${entityName}s`,
})}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.refreshing_will_update_the_data`}
/>
</Form.Text>
</Form.Group>
<div className="mt-4">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
<b>
<FormattedMessage
id={`${localePrefix}.number_of_${entityName}s_will_be_processed`}
values={{
[countVariableName]: entityCount,
}}
/>
</b>
</ModalComponent>
);
};
interface IBatchAddModalProps {
isIdle: boolean;
onBatchAdd: (input: string) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
localePrefix: string;
entityName: string;
}
export const BatchAddModal: React.FC<IBatchAddModalProps> = ({
isIdle,
onBatchAdd,
batchAddParents,
setBatchAddParents,
close,
localePrefix,
entityName,
}) => {
const intl = useIntl();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
return (
<ModalComponent
show
icon={faStar}
header={intl.formatMessage({
id: `${localePrefix}.add_new_${entityName}s`,
})}
accept={{
text: intl.formatMessage({
id: `${localePrefix}.add_new_${entityName}s`,
}),
onClick: () => {
if (inputRef.current) {
onBatchAdd(inputRef.current.value);
} else {
close();
}
},
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Control
className="text-input"
as="textarea"
ref={inputRef}
placeholder={intl.formatMessage({
id: `${localePrefix}.${entityName}_names_or_stashids_separated_by_comma`,
})}
rows={6}
/>
<Form.Text>
<FormattedMessage
id={`${localePrefix}.any_names_entered_will_be_queried`}
/>
</Form.Text>
<div className="mt-2">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: `${localePrefix}.create_or_tag_parent_${entityName}s`,
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
</ModalComponent>
);
};

View file

@ -38,6 +38,7 @@ export const initialConfig: ITaggerConfig = {
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,
createParentStudios: true,
createParentTags: true,
};
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
@ -56,6 +57,7 @@ export interface ITaggerConfig {
excludedStudioFields?: string[];
excludedTagFields?: string[];
createParentStudios: boolean;
createParentTags: boolean;
}
export const PERFORMER_FIELDS = [
@ -85,4 +87,4 @@ export const PERFORMER_FIELDS = [
];
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];
export const TAG_FIELDS = ["name", "description", "aliases"];
export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"];

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom";
@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ModalComponent } from "src/components/Shared/Modal";
import {
stashBoxStudioQuery,
useJobsSubscribe,
@ -25,11 +24,15 @@ import { ITaggerConfig, STUDIO_FIELDS } from "../constants";
import StudioModal from "../scenes/StudioModal";
import { useUpdateStudio } from "../queries";
import { apolloError } from "src/utils";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
import { faTags } from "@fortawesome/free-solid-svg-icons";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { mergeStudioStashIDs } from "../utils";
import { separateNamesAndStashIds } from "src/utils/stashIds";
import { useTaggerConfig } from "../config";
import {
BatchUpdateModal,
BatchAddModal,
} from "src/components/Shared/BatchModals";
type JobFragment = Pick<
GQL.Job,
@ -38,232 +41,6 @@ type JobFragment = Pick<
const CLASSNAME = "StudioTagger";
interface IStudioBatchUpdateModal {
studios: GQL.StudioDataFragment[];
isIdle: boolean;
selectedEndpoint: { endpoint: string; index: number };
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
}
const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
studios,
isIdle,
selectedEndpoint,
onBatchUpdate,
batchAddParents,
setBatchAddParents,
close,
}) => {
const intl = useIntl();
const [queryAll, setQueryAll] = useState(false);
const [refresh, setRefresh] = useState(false);
const { data: allStudios } = GQL.useFindStudiosQuery({
variables: {
studio_filter: {
stash_id_endpoint: {
endpoint: selectedEndpoint.endpoint,
modifier: refresh
? GQL.CriterionModifier.NotNull
: GQL.CriterionModifier.IsNull,
},
},
filter: {
per_page: 0,
},
},
});
const studioCount = useMemo(() => {
// get all stash ids for the selected endpoint
const filteredStashIDs = studios.map((p) =>
p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
);
return queryAll
? allStudios?.findStudios.count
: filteredStashIDs.filter((s) =>
// if refresh, then we filter out the studios without a stash id
// otherwise, we want untagged studios, filtering out those with a stash id
refresh ? s.length > 0 : s.length === 0
).length;
}, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]);
return (
<ModalComponent
show
icon={faTags}
header={intl.formatMessage({
id: "studio_tagger.update_studios",
})}
accept={{
text: intl.formatMessage({
id: "studio_tagger.update_studios",
}),
onClick: () => onBatchUpdate(queryAll, refresh),
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id="studio_tagger.studio_selection" />
</h6>
</Form.Label>
<Form.Check
id="query-page"
type="radio"
name="studio-query"
label={<FormattedMessage id="studio_tagger.current_page" />}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
id="query-all"
type="radio"
name="studio-query"
label={intl.formatMessage({
id: "studio_tagger.query_all_studios_in_the_database",
})}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id="studio_tagger.tag_status" />
</h6>
</Form.Label>
<Form.Check
id="untagged-studios"
type="radio"
name="studio-refresh"
label={intl.formatMessage({
id: "studio_tagger.untagged_studios",
})}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
<FormattedMessage id="studio_tagger.updating_untagged_studios_description" />
</Form.Text>
<Form.Check
id="tagged-studios"
type="radio"
name="studio-refresh"
label={intl.formatMessage({
id: "studio_tagger.refresh_tagged_studios",
})}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
<FormattedMessage id="studio_tagger.refreshing_will_update_the_data" />
</Form.Text>
<div className="mt-4">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: "studio_tagger.create_or_tag_parent_studios",
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
</Form.Group>
<b>
<FormattedMessage
id="studio_tagger.number_of_studios_will_be_processed"
values={{
studio_count: studioCount,
}}
/>
</b>
</ModalComponent>
);
};
interface IStudioBatchAddModal {
isIdle: boolean;
onBatchAdd: (input: string) => void;
batchAddParents: boolean;
setBatchAddParents: (addParents: boolean) => void;
close: () => void;
}
const StudioBatchAddModal: React.FC<IStudioBatchAddModal> = ({
isIdle,
onBatchAdd,
batchAddParents,
setBatchAddParents,
close,
}) => {
const intl = useIntl();
const studioInput = useRef<HTMLTextAreaElement | null>(null);
return (
<ModalComponent
show
icon={faStar}
header={intl.formatMessage({
id: "studio_tagger.add_new_studios",
})}
accept={{
text: intl.formatMessage({
id: "studio_tagger.add_new_studios",
}),
onClick: () => {
if (studioInput.current) {
onBatchAdd(studioInput.current.value);
} else {
close();
}
},
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Control
className="text-input"
as="textarea"
ref={studioInput}
placeholder={intl.formatMessage({
id: "studio_tagger.studio_names_or_stashids_separated_by_comma",
})}
rows={6}
/>
<Form.Text>
<FormattedMessage id="studio_tagger.any_names_entered_will_be_queried" />
</Form.Text>
<div className="mt-2">
<Form.Check
id="add-parent"
checked={batchAddParents}
label={intl.formatMessage({
id: "studio_tagger.create_or_tag_parent_studios",
})}
onChange={() => setBatchAddParents(!batchAddParents)}
/>
</div>
</ModalComponent>
);
};
interface IStudioTaggerListProps {
studios: GQL.StudioDataFragment[];
selectedEndpoint: { endpoint: string; index: number };
@ -305,6 +82,24 @@ const StudioTaggerList: React.FC<IStudioTaggerListProps> = ({
config.createParentStudios || false
);
const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);
const { data: allStudios } = GQL.useFindStudiosQuery({
skip: !showBatchUpdate,
variables: {
studio_filter: {
stash_id_endpoint: {
endpoint: selectedEndpoint.endpoint,
modifier: batchUpdateRefresh
? GQL.CriterionModifier.NotNull
: GQL.CriterionModifier.IsNull,
},
},
filter: {
per_page: 0,
},
},
});
const [error, setError] = useState<
Record<string, { message?: string; details?: string } | undefined>
>({});
@ -630,24 +425,31 @@ const StudioTaggerList: React.FC<IStudioTaggerListProps> = ({
return (
<Card>
{showBatchUpdate && (
<StudioBatchUpdateModal
<BatchUpdateModal
close={() => setShowBatchUpdate(false)}
isIdle={isIdle}
selectedEndpoint={selectedEndpoint}
studios={studios}
entities={studios}
allCount={allStudios?.findStudios.count}
onBatchUpdate={handleBatchUpdate}
onRefreshChange={setBatchUpdateRefresh}
batchAddParents={batchAddParents}
setBatchAddParents={setBatchAddParents}
localePrefix="studio_tagger"
entityName="studio"
countVariableName="studio_count"
/>
)}
{showBatchAdd && (
<StudioBatchAddModal
<BatchAddModal
close={() => setShowBatchAdd(false)}
isIdle={isIdle}
onBatchAdd={handleBatchAdd}
batchAddParents={batchAddParents}
setBatchAddParents={setBatchAddParents}
localePrefix="studio_tagger"
entityName="studio"
/>
)}
<div className="ml-auto mb-3">

View file

@ -287,7 +287,8 @@
}
}
.StudioTagger {
.StudioTagger,
.TagTagger {
display: flex;
flex-wrap: wrap;
justify-content: center;
@ -342,7 +343,8 @@
vertical-align: bottom;
}
&-studio-search {
&-studio-search,
&-tag-search {
display: flex;
flex-wrap: wrap;

View file

@ -7,6 +7,8 @@ import TagModal from "./TagModal";
import { faTags } from "@fortawesome/free-solid-svg-icons";
import { useIntl } from "react-intl";
import { mergeTagStashIDs } from "../utils";
import { useTagCreate } from "src/core/StashService";
import { apolloError } from "src/utils";
interface IStashSearchResultProps {
tag: GQL.TagListDataFragment;
@ -34,13 +36,49 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{}
);
const [createTag] = useTagCreate();
const updateTag = useUpdateTag();
const handleSave = async (input: GQL.TagCreateInput) => {
function handleSaveError(name: string, message: string) {
setError({
message: intl.formatMessage(
{ id: "tag_tagger.failed_to_save_tag" },
{ tag: name }
),
details:
message === "UNIQUE constraint failed: tags.name"
? intl.formatMessage({
id: "tag_tagger.name_already_exists",
})
: message,
});
}
const handleSave = async (
input: GQL.TagCreateInput,
parentInput?: GQL.TagCreateInput
) => {
setError({});
setModalTag(undefined);
setSaveState("Saving tag");
if (parentInput) {
setSaveState("Saving parent tag");
try {
const parentRes = await createTag({
variables: { input: parentInput },
});
input.parent_ids = [parentRes.data?.tagCreate?.id].filter(
Boolean
) as string[];
} catch (e) {
handleSaveError(parentInput.name, apolloError(e));
setSaveState("");
return;
}
}
setSaveState("Saving tag");
const updateData: GQL.TagUpdateInput = {
...input,
id: tag.id,
@ -54,18 +92,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
const res = await updateTag(updateData);
if (!res?.data?.tagUpdate) {
setError({
message: intl.formatMessage(
{ id: "tag_tagger.failed_to_save_tag" },
{ tag: input.name ?? tag.name }
),
details:
res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name"
? intl.formatMessage({
id: "tag_tagger.name_already_exists",
})
: res?.errors?.[0]?.message ?? "",
});
handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? "");
} else {
onTagTagged(tag);
}
@ -74,7 +101,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
const tags = stashboxTags.map((p) => (
<Button
className="StudioTagger-studio-search-item minimal col-6"
className="TagTagger-tag-search-item minimal col-6"
variant="link"
key={p.remote_site_id}
onClick={() => setModalTag(p)}
@ -97,7 +124,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
endpoint={endpoint}
/>
)}
<div className="StudioTagger-studio-search">{tags}</div>
<div className="TagTagger-tag-search">{tags}</div>
<div className="row no-gutters mt-2 align-items-center justify-content-end">
{error.message && (
<div className="text-right text-danger mt-1">

View file

@ -10,7 +10,7 @@ import {
faExternalLinkAlt,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { Button } from "react-bootstrap";
import { Button, Form } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { excludeFields } from "src/utils/data";
import { ExternalLink } from "src/components/Shared/ExternalLink";
@ -19,7 +19,7 @@ interface ITagModalProps {
tag: GQL.ScrapedSceneTagDataFragment;
modalVisible: boolean;
closeModal: () => void;
onSave: (input: GQL.TagCreateInput) => void;
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
excludedTagFields?: string[];
header: string;
icon: IconDefinition;
@ -47,19 +47,58 @@ const TagModal: React.FC<ITagModalProps> = ({
[name]: !excluded[name],
});
function maybeRenderField(id: string, text: string | null | undefined) {
const [createParentTag, setCreateParentTag] = useState<boolean>(
!!tag.parent && !tag.parent.stored_id
);
// Check if a tag with the parent name already exists locally.
// Categories don't have stash IDs, so stored_id may be null even when the
// parent tag has already been created (e.g. by tagging a sibling tag first).
const parentNameQuery = GQL.useFindTagsQuery({
skip: !tag.parent || !!tag.parent.stored_id,
variables: {
tag_filter: {
name: {
value: tag.parent?.name ?? "",
modifier: GQL.CriterionModifier.Equals,
},
},
filter: { per_page: 1 },
},
});
const existingParentId = parentNameQuery.data?.findTags.tags[0]?.id;
// If the parent already exists locally, don't offer to create it
const sendParentTag = !existingParentId;
const [parentExcluded, setParentExcluded] = useState<Record<string, boolean>>(
excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleParentField = (name: string) =>
setParentExcluded({
...parentExcluded,
[name]: !parentExcluded[name],
});
function maybeRenderField(
id: string,
text: string | null | undefined,
isSelectable: boolean = true
) {
if (!text) return;
return (
<div className="row no-gutters">
<div className="col-5 studio-create-modal-field" key={id}>
<Button
onClick={() => toggleField(id)}
variant="secondary"
className={excluded[id] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[id] ? faTimes : faCheck} />
</Button>
{isSelectable && (
<Button
onClick={() => toggleField(id)}
variant="secondary"
className={excluded[id] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[id] ? faTimes : faCheck} />
</Button>
)}
<strong>
<FormattedMessage id={id} />:
</strong>
@ -85,15 +124,82 @@ const TagModal: React.FC<ITagModalProps> = ({
);
}
function maybeRenderParentField(
id: string,
text: string | null | undefined,
isSelectable: boolean = true
) {
if (!text) return;
return (
<div className="row no-gutters">
<div className="col-5 studio-create-modal-field" key={id}>
{isSelectable && (
<Button
onClick={() => toggleParentField(id)}
variant="secondary"
className={parentExcluded[id] ? "text-muted" : "text-success"}
>
<Icon icon={parentExcluded[id] ? faTimes : faCheck} />
</Button>
)}
<strong>
<FormattedMessage id={id} />:
</strong>
</div>
<TruncatedText className="col-7" text={text} />
</div>
);
}
function maybeRenderParentTagDetails() {
if (!createParentTag || !tag.parent) {
return;
}
return (
<div>
{maybeRenderParentField("name", tag.parent.name, false)}
{maybeRenderParentField("description", tag.parent.description)}
</div>
);
}
function maybeRenderParentTag() {
// No parent tag, or parent already exists locally
if (!tag.parent || tag.parent.stored_id || !sendParentTag) {
return;
}
return (
<div>
<div className="mb-4 mt-4">
<Form.Check
id="create-parent"
checked={createParentTag}
label={intl.formatMessage({
id: "actions.create_parent_tag",
})}
onChange={() => setCreateParentTag(!createParentTag)}
/>
</div>
{maybeRenderParentTagDetails()}
</div>
);
}
function handleSave() {
if (!tag.name) {
throw new Error("tag name must be set");
}
const parentId = tag.parent?.stored_id ?? existingParentId;
const tagData: GQL.TagCreateInput = {
name: tag.name,
description: tag.description ?? undefined,
aliases: tag.alias_list?.filter((a) => a) ?? undefined,
parent_ids: parentId ? [parentId] : undefined,
};
// stashid handling code
@ -111,7 +217,27 @@ const TagModal: React.FC<ITagModalProps> = ({
// handle exclusions
excludeFields(tagData, excluded);
onSave(tagData);
let parentData: GQL.TagCreateInput | undefined = undefined;
// Categories don't have stash IDs, so we only create new parent tags
if (
createParentTag &&
sendParentTag &&
tag.parent &&
!tag.parent.stored_id
) {
parentData = {
name: tag.parent.name,
description: tag.parent.description ?? undefined,
};
// handle exclusions
// Can't exclude parent tag name when creating a new one
parentExcluded.name = false;
excludeFields(parentData, parentExcluded);
}
onSave(tagData, parentData);
}
return (
@ -133,10 +259,12 @@ const TagModal: React.FC<ITagModalProps> = ({
{maybeRenderField("name", tag.name)}
{maybeRenderField("description", tag.description)}
{maybeRenderField("aliases", tag.alias_list?.join(", "))}
{maybeRenderField("parent_tags", tag.parent?.name, false)}
{maybeRenderStashBoxLink()}
</div>
</div>
</div>
{maybeRenderParentTag()}
</ModalComponent>
);
};

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom";
@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ModalComponent } from "src/components/Shared/Modal";
import {
stashBoxTagQuery,
useJobsSubscribe,
@ -20,221 +19,33 @@ import StashSearchResult from "./StashSearchResult";
import TaggerConfig from "../TaggerConfig";
import { ITaggerConfig, TAG_FIELDS } from "../constants";
import { useUpdateTag } from "../queries";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { mergeTagStashIDs } from "../utils";
import { separateNamesAndStashIds } from "src/utils/stashIds";
import { useTaggerConfig } from "../config";
import {
BatchUpdateModal,
BatchAddModal,
} from "src/components/Shared/BatchModals";
type JobFragment = Pick<
GQL.Job,
"id" | "status" | "subTasks" | "description" | "progress"
>;
const CLASSNAME = "StudioTagger";
interface ITagBatchUpdateModal {
tags: GQL.TagListDataFragment[];
isIdle: boolean;
selectedEndpoint: { endpoint: string; index: number };
onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;
close: () => void;
}
const TagBatchUpdateModal: React.FC<ITagBatchUpdateModal> = ({
tags,
isIdle,
selectedEndpoint,
onBatchUpdate,
close,
}) => {
const intl = useIntl();
const [queryAll, setQueryAll] = useState(false);
const [refresh, setRefresh] = useState(false);
const { data: allTags } = GQL.useFindTagsQuery({
variables: {
tag_filter: {
stash_id_endpoint: {
endpoint: selectedEndpoint.endpoint,
modifier: refresh
? GQL.CriterionModifier.NotNull
: GQL.CriterionModifier.IsNull,
},
},
filter: {
per_page: 0,
},
},
});
const tagCount = useMemo(() => {
const filteredStashIDs = tags.map((t) =>
t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)
);
return queryAll
? allTags?.findTags.count
: filteredStashIDs.filter((s) =>
refresh ? s.length > 0 : s.length === 0
).length;
}, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]);
return (
<ModalComponent
show
icon={faTags}
header={intl.formatMessage({
id: "tag_tagger.update_tags",
})}
accept={{
text: intl.formatMessage({
id: "tag_tagger.update_tags",
}),
onClick: () => onBatchUpdate(queryAll, refresh),
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id="tag_tagger.tag_selection" />
</h6>
</Form.Label>
<Form.Check
id="query-page"
type="radio"
name="tag-query"
label={<FormattedMessage id="tag_tagger.current_page" />}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
id="query-all"
type="radio"
name="tag-query"
label={intl.formatMessage({
id: "tag_tagger.query_all_tags_in_the_database",
})}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>
<FormattedMessage id="tag_tagger.tag_status" />
</h6>
</Form.Label>
<Form.Check
id="untagged-tags"
type="radio"
name="tag-refresh"
label={intl.formatMessage({
id: "tag_tagger.untagged_tags",
})}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
<FormattedMessage id="tag_tagger.updating_untagged_tags_description" />
</Form.Text>
<Form.Check
id="tagged-tags"
type="radio"
name="tag-refresh"
label={intl.formatMessage({
id: "tag_tagger.refresh_tagged_tags",
})}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
<FormattedMessage id="tag_tagger.refreshing_will_update_the_data" />
</Form.Text>
</Form.Group>
<b>
<FormattedMessage
id="tag_tagger.number_of_tags_will_be_processed"
values={{
tag_count: tagCount,
}}
/>
</b>
</ModalComponent>
);
};
interface ITagBatchAddModal {
isIdle: boolean;
onBatchAdd: (input: string) => void;
close: () => void;
}
const TagBatchAddModal: React.FC<ITagBatchAddModal> = ({
isIdle,
onBatchAdd,
close,
}) => {
const intl = useIntl();
const tagInput = useRef<HTMLTextAreaElement | null>(null);
return (
<ModalComponent
show
icon={faStar}
header={intl.formatMessage({
id: "tag_tagger.add_new_tags",
})}
accept={{
text: intl.formatMessage({
id: "tag_tagger.add_new_tags",
}),
onClick: () => {
if (tagInput.current) {
onBatchAdd(tagInput.current.value);
} else {
close();
}
},
}}
cancel={{
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "danger",
onClick: () => close(),
}}
disabled={!isIdle}
>
<Form.Control
className="text-input"
as="textarea"
ref={tagInput}
placeholder={intl.formatMessage({
id: "tag_tagger.tag_names_or_stashids_separated_by_comma",
})}
rows={6}
/>
<Form.Text>
<FormattedMessage id="tag_tagger.any_names_entered_will_be_queried" />
</Form.Text>
</ModalComponent>
);
};
const CLASSNAME = "TagTagger";
interface ITagTaggerListProps {
tags: GQL.TagListDataFragment[];
selectedEndpoint: { endpoint: string; index: number };
isIdle: boolean;
config: ITaggerConfig;
onBatchAdd: (tagInput: string) => void;
onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void;
onBatchAdd: (tagInput: string, createParent: boolean) => void;
onBatchUpdate: (
ids: string[] | undefined,
refresh: boolean,
createParent: boolean
) => void;
}
const TagTaggerList: React.FC<ITagTaggerListProps> = ({
@ -261,6 +72,27 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
const [showBatchAdd, setShowBatchAdd] = useState(false);
const [showBatchUpdate, setShowBatchUpdate] = useState(false);
const [batchAddParents, setBatchAddParents] = useState(
config.createParentTags || false
);
const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);
const { data: allTags } = GQL.useFindTagsQuery({
skip: !showBatchUpdate,
variables: {
tag_filter: {
stash_id_endpoint: {
endpoint: selectedEndpoint.endpoint,
modifier: batchUpdateRefresh
? GQL.CriterionModifier.NotNull
: GQL.CriterionModifier.IsNull,
},
},
filter: {
per_page: 0,
},
},
});
const [error, setError] = useState<
Record<string, { message?: string; details?: string } | undefined>
@ -360,12 +192,16 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
};
async function handleBatchAdd(input: string) {
onBatchAdd(input);
onBatchAdd(input, batchAddParents);
setShowBatchAdd(false);
}
const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {
onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh);
onBatchUpdate(
!queryAll ? tags.map((t) => t.id) : undefined,
refresh,
batchAddParents
);
setShowBatchUpdate(false);
};
@ -451,7 +287,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
subContent = (
<div key={tag.id}>
<InputGroup className="StudioTagger-box-link">
<InputGroup className="TagTagger-box-link">
<InputGroup.Text>{link}</InputGroup.Text>
<InputGroup.Append>
<Button
@ -532,20 +368,31 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
return (
<Card>
{showBatchUpdate && (
<TagBatchUpdateModal
<BatchUpdateModal
close={() => setShowBatchUpdate(false)}
isIdle={isIdle}
selectedEndpoint={selectedEndpoint}
tags={tags}
entities={tags}
allCount={allTags?.findTags.count}
onBatchUpdate={handleBatchUpdate}
onRefreshChange={setBatchUpdateRefresh}
batchAddParents={batchAddParents}
setBatchAddParents={setBatchAddParents}
localePrefix="tag_tagger"
entityName="tag"
countVariableName="tag_count"
/>
)}
{showBatchAdd && (
<TagBatchAddModal
<BatchAddModal
close={() => setShowBatchAdd(false)}
isIdle={isIdle}
onBatchAdd={handleBatchAdd}
batchAddParents={batchAddParents}
setBatchAddParents={setBatchAddParents}
localePrefix="tag_tagger"
entityName="tag"
/>
)}
<div className="ml-auto mb-3">
@ -611,7 +458,7 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
const selectedEndpoint =
stashConfig?.general.stashBoxes[selectedEndpointIndex];
async function batchAdd(tagInput: string) {
async function batchAdd(tagInput: string, createParent: boolean) {
if (tagInput && selectedEndpoint) {
const inputs = tagInput
.split(",")
@ -626,7 +473,7 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
stash_ids: stashIds.length > 0 ? stashIds : undefined,
endpoint: selectedEndpointIndex,
refresh: false,
createParent: false,
createParent: createParent,
exclude_fields: config?.excludedTagFields ?? [],
});
@ -635,13 +482,17 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
}
}
async function batchUpdate(ids: string[] | undefined, refresh: boolean) {
async function batchUpdate(
ids: string[] | undefined,
refresh: boolean,
createParent: boolean
) {
if (selectedEndpoint) {
const ret = await mutateStashBoxBatchTagTag({
ids: ids,
endpoint: selectedEndpointIndex,
refresh,
createParent: false,
createParent: createParent,
exclude_fields: config?.excludedTagFields ?? [],
});
@ -721,6 +572,28 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
}
fields={TAG_FIELDS}
entityName="tags"
extraConfig={
<Form.Group
controlId="create-parent"
className="align-items-center"
>
<Form.Check
label={
<FormattedMessage id="tag_tagger.config.create_parent_label" />
}
checked={config.createParentTags}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig({
...config,
createParentTags: e.currentTarget.checked,
})
}
/>
<Form.Text>
<FormattedMessage id="tag_tagger.config.create_parent_desc" />
</Form.Text>
</Form.Group>
}
/>
<TagTaggerList
tags={tags}

View file

@ -36,6 +36,7 @@
"create_marker": "Create Marker",
"create_new": "Create new",
"create_parent_studio": "Create parent studio",
"create_parent_tag": "Create parent tag",
"created_entity": "Created {entity_type}: {entity_name}",
"customise": "Customise",
"delete": "Delete",
@ -1605,6 +1606,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_tags": "Batch Add Tags",
"batch_update_tags": "Batch Update Tags",
"config": {
"create_parent_desc": "Create missing parent tags from stash-box categories, or tag existing parent tags with exact name matches",
"create_parent_label": "Create parent tags"
},
"create_or_tag_parent_tags": "Create missing or tag existing parent tags",
"current_page": "Current page",
"failed_to_save_tag": "Failed to save tag \"{tag}\"",
"name_already_exists": "Name already exists",