FR: Tags Tagger (#6559)

* Refactor Tagger components
* condense localization
* add alias and description to model and schema
This commit is contained in:
Gykes 2026-02-24 16:39:14 -08:00 committed by GitHub
parent 14105a2d54
commit 0103fe4751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1702 additions and 262 deletions

View file

@ -583,6 +583,8 @@ type Mutation {
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
"Run batch studio tag task. Returns the job ID."
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
"Run batch tag tag task. Returns the job ID."
stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
enableDLNA(input: EnableDLNAInput!): Boolean!

View file

@ -71,6 +71,8 @@ type ScrapedTag {
"Set if tag matched"
stored_id: ID
name: String!
description: String
alias_list: [String!]
"Remote site ID, if applicable"
remote_site_id: String
}

View file

@ -29,6 +29,8 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
description
aliases
}
fragment MeasurementsFragment on Measurements {

View file

@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil {
return "", err
}
jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
if err != nil {

View file

@ -704,3 +704,133 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j)
}
func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
tagQuery := s.Repository.Tag
for _, tagID := range input.Ids {
if id, err := strconv.Atoi(tagID); err == nil {
t, err := tagQuery.Find(ctx, id)
if err != nil {
return err
}
if err := t.LoadStashIDs(ctx, tagQuery); err != nil {
return fmt.Errorf("loading tag stash ids: %w", err)
}
hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
stashID: &stashID,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
name: &name,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
tagQuery := s.Repository.Tag
var tags []*models.Tag
var err error
tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying tags: %v", err)
}
for _, t := range tags {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch tag tag")
var tasks []Task
var err error
switch input.getBatchTagType(false) {
case batchTagByIds:
tasks, err = s.batchTagTagsByIds(ctx, input, box)
case batchTagByNamesOrStashIds:
tasks = s.batchTagTagsByNamesOrStashIds(input, box)
case batchTagAll:
tasks, err = s.batchTagAllTags(ctx, input, box)
}
if err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
progress.SetTotal(len(tasks))
logger.Infof("Starting stash-box batch operation for %d tags", len(tasks))
for _, task := range tasks {
progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx)
})
progress.Increment()
}
return nil
})
return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j)
}

View file

@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/stashbox"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/tag"
)
// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
@ -529,3 +530,175 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
return err
}
}
// stashBoxBatchTagTagTask is used to tag or create tags from stash-box.
//
// Two modes of operation:
// - Update existing tag: set tag to update from stash-box data
// - Create new tag: set name or stashID to search stash-box and create locally
type stashBoxBatchTagTagTask struct {
box *models.StashBox
name *string
stashID *string
tag *models.Tag
excludedFields []string
}
func (t *stashBoxBatchTagTagTask) getName() string {
switch {
case t.name != nil:
return *t.name
case t.stashID != nil:
return *t.stashID
case t.tag != nil:
return t.tag.Name
default:
return ""
}
}
func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) {
scrapedTag, err := t.findStashBoxTag(ctx)
if err != nil {
logger.Errorf("Error fetching tag data from stash-box: %v", err)
return
}
excluded := map[string]bool{}
for _, field := range t.excludedFields {
excluded[field] = true
}
if scrapedTag != nil {
t.processMatchedTag(ctx, scrapedTag, excluded)
} else {
logger.Infof("No match found for %s", t.getName())
}
}
func (t *stashBoxBatchTagTagTask) GetDescription() string {
return fmt.Sprintf("Tagging tag %s from stash-box", t.getName())
}
func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) {
var results []*models.ScrapedTag
var err error
r := instance.Repository
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
switch {
case t.name != nil:
results, err = client.QueryTag(ctx, *t.name)
case t.stashID != nil:
results, err = client.QueryTag(ctx, *t.stashID)
case t.tag != nil:
var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if !t.tag.StashIDs.Loaded() {
err = t.tag.LoadStashIDs(ctx, r.Tag)
if err != nil {
return err
}
}
for _, id := range t.tag.StashIDs.List() {
if id.Endpoint == t.box.Endpoint {
remoteID = id.StashID
}
}
return nil
}); err != nil {
return nil, err
}
if remoteID != "" {
results, err = client.QueryTag(ctx, remoteID)
} else {
results, err = client.QueryTag(ctx, t.tag.Name)
}
}
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, nil
}
result := results[0]
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
}); err != nil {
return nil, err
}
return result, 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
// already exists locally).
tagID := 0
if t.tag != nil {
tagID = t.tag.ID
} else if s.StoredID != nil {
tagID, _ = strconv.Atoi(*s.StoredID)
}
if tagID > 0 {
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
existingStashIDs, err := qb.GetStashIDs(ctx, tagID)
if err != nil {
return err
}
storedID := strconv.Itoa(tagID)
partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs)
if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil {
return err
}
return nil
})
if err != nil {
logger.Errorf("Failed to update tag %s: %v", s.Name, err)
} else {
logger.Infof("Updated tag %s", s.Name)
}
} else if s.Name != "" {
// no existing tag, create a new one
newTag := s.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, *newTag, qb); err != nil {
return err
}
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil {
return err
}
return nil
})
if err != nil {
logger.Errorf("Failed to create tag %s: %v", s.Name, err)
} else {
logger.Infof("Created tag %s", s.Name)
}
}
}

View file

@ -450,6 +450,29 @@ func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.Sta
return r0, r1
}
// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint
func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {
ret := _m.Called(ctx, hasStashID, stashboxEndpoint)
var r0 []*models.Tag
if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok {
r0 = rf(ctx, hasStashID, stashboxEndpoint)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok {
r1 = rf(ctx, hasStashID, stashboxEndpoint)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByStudioID provides a mock function with given fields: ctx, studioID
func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, studioID)

View file

@ -471,9 +471,11 @@ 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"`
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"`
}
func (ScrapedTag) IsScrapedContent() {}
@ -482,6 +484,17 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
currentTime := time.Now()
ret := NewTag()
ret.Name = t.Name
ret.ParentIDs = NewRelatedIDs([]int{})
ret.ChildIDs = NewRelatedIDs([]int{})
ret.Aliases = NewRelatedStrings([]string{})
if t.Description != nil && !excluded["description"] {
ret.Description = *t.Description
}
if len(t.AliasList) > 0 && !excluded["aliases"] {
ret.Aliases = NewRelatedStrings(t.AliasList)
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
@ -496,6 +509,39 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
return &ret
}
func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial {
ret := NewTagPartial()
if t.Name != "" && !excluded["name"] {
ret.Name = NewOptionalString(t.Name)
}
if t.Description != nil && !excluded["description"] {
ret.Description = NewOptionalString(*t.Description)
}
if len(t.AliasList) > 0 && !excluded["aliases"] {
ret.Aliases = &UpdateStrings{
Values: t.AliasList,
Mode: RelationshipUpdateModeSet,
}
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
}
ret.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *t.RemoteSiteID,
UpdatedAt: time.Now(),
})
}
return ret
}
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
}

View file

@ -26,6 +26,7 @@ type TagFinder interface {
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
}
// TagQueryer provides methods to query tags.

View file

@ -597,6 +597,36 @@ func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) (
return ret, nil
}
func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {
table := qb.table()
sq := dialect.From(table).LeftJoin(
tagsStashIDsJoinTable,
goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))),
).Select(table.Col(idColumn))
if hasStashID {
sq = sq.Where(
tagsStashIDsJoinTable.Col("stash_id").IsNotNull(),
tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint),
)
} else {
sq = sq.Where(
tagsStashIDsJoinTable.Col("stash_id").IsNull(),
)
}
idsQuery := qb.selectDataset().Where(
table.Col(idColumn).In(sq),
)
ret, err := qb.getMany(ctx, idsQuery)
if err != nil {
return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err)
}
return ret, nil
}
func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
return tagsParentTagsTableMgr.get(ctx, relatedID)
}

View file

@ -128,8 +128,10 @@ func (t *StudioFragment) GetImages() []*ImageFragment {
}
type TagFragment struct {
Name string "json:\"name\" graphql:\"name\""
ID string "json:\"id\" graphql:\"id\""
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\""
}
func (t *TagFragment) GetName() string {
@ -144,6 +146,18 @@ func (t *TagFragment) GetID() string {
}
return t.ID
}
func (t *TagFragment) GetDescription() *string {
if t == nil {
t = &TagFragment{}
}
return t.Description
}
func (t *TagFragment) GetAliases() []string {
if t == nil {
t = &TagFragment{}
}
return t.Aliases
}
type MeasurementsFragment struct {
BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\""
@ -849,6 +863,8 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
description
aliases
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -985,6 +1001,8 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
description
aliases
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -1279,6 +1297,8 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
description
aliases
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@ -1413,6 +1433,8 @@ const FindTagDocument = `query FindTag ($id: ID, $name: String) {
fragment TagFragment on Tag {
name
id
description
aliases
}
`
@ -1445,6 +1467,8 @@ const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) {
fragment TagFragment on Tag {
name
id
description
aliases
}
`

View file

@ -31,10 +31,8 @@ func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTa
return nil, nil
}
return []*models.ScrapedTag{{
Name: tag.FindTag.Name,
RemoteSiteID: &tag.FindTag.ID,
}}, nil
ret := tagFragmentToScrapedTag(*tag.FindTag)
return []*models.ScrapedTag{ret}, nil
}
func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) {
@ -57,11 +55,22 @@ func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.Scr
var ret []*models.ScrapedTag
for _, t := range result.QueryTags.Tags {
ret = append(ret, &models.ScrapedTag{
Name: t.Name,
RemoteSiteID: &t.ID,
})
ret = append(ret, tagFragmentToScrapedTag(*t))
}
return ret, nil
}
func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {
ret := &models.ScrapedTag{
Name: t.Name,
Description: t.Description,
RemoteSiteID: &t.ID,
}
if len(t.Aliases) > 0 {
ret.AliasList = t.Aliases
}
return ret
}

View file

@ -160,6 +160,8 @@ fragment ScrapedSceneStudioData on ScrapedStudio {
fragment ScrapedSceneTagData on ScrapedTag {
stored_id
name
description
alias_list
remote_site_id
}

View file

@ -12,6 +12,10 @@ mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) {
stashBoxBatchStudioTag(input: $input)
}
mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) {
stashBoxBatchTagTag(input: $input)
}
mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {
submitStashBoxSceneDraft(input: $input)
}

View file

@ -395,7 +395,13 @@ export const ScrapedTagsRow: React.FC<
onSelect={(items) => {
if (onChangeFn) {
// map the id back to stored_id
onChangeFn(items.map((p) => ({ ...p, stored_id: p.id })));
onChangeFn(
items.map((p) => ({
...p,
stored_id: p.id,
alias_list: p.aliases,
}))
);
}
}}
ids={selectValue}

View file

@ -5,22 +5,25 @@ import { useIntl } from "react-intl";
import { ModalComponent } from "../Shared/Modal";
import { Icon } from "../Shared/Icon";
import { PERFORMER_FIELDS } from "./constants";
interface IProps {
show: boolean;
fields: string[];
excludedFields: string[];
onSelect: (fields: string[]) => void;
}
const PerformerFieldSelect: React.FC<IProps> = ({
const FieldSelector: React.FC<IProps> = ({
show,
fields,
excludedFields,
onSelect,
}) => {
const intl = useIntl();
const [excluded, setExcluded] = useState<Record<string, boolean>>(
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
excludedFields
.filter((field) => fields.includes(field))
.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (field: string) =>
@ -57,9 +60,9 @@ const PerformerFieldSelect: React.FC<IProps> = ({
<div className="mb-2">
These fields will be tagged by default. Click the button to toggle.
</div>
<Row>{PERFORMER_FIELDS.map((f) => renderField(f))}</Row>
<Row>{fields.map((f) => renderField(f))}</Row>
</ModalComponent>
);
};
export default PerformerFieldSelect;
export default FieldSelector;

View file

@ -3,21 +3,33 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { useConfigurationContext } from "src/hooks/Config";
import { ITaggerConfig } from "../constants";
import PerformerFieldSelector from "../PerformerFieldSelector";
import { ITaggerConfig } from "./constants";
import FieldSelector from "./FieldSelector";
interface IConfigProps {
interface ITaggerConfigProps {
show: boolean;
config: ITaggerConfig;
setConfig: Dispatch<ITaggerConfig>;
excludedFields: string[];
onFieldsChange: (fields: string[]) => void;
fields: string[];
entityName: string;
extraConfig?: React.ReactNode;
}
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
const TaggerConfig: React.FC<ITaggerConfigProps> = ({
show,
config,
setConfig,
excludedFields,
onFieldsChange,
fields,
entityName,
extraConfig,
}) => {
const { configuration: stashConfig } = useConfigurationContext();
const [showExclusionModal, setShowExclusionModal] = useState(false);
const excludedFields = config.excludedPerformerFields ?? [];
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedEndpoint = e.currentTarget.value;
setConfig({
@ -28,8 +40,8 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
const handleFieldSelect = (fields: string[]) => {
setConfig({ ...config, excludedPerformerFields: fields });
const handleFieldSelect = (selectedFields: string[]) => {
onFieldsChange(selectedFields);
setShowExclusionModal(false);
};
@ -43,9 +55,10 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</h4>
<hr className="w-100" />
<div className="col-md-6">
<Form.Group controlId="excluded-performer-fields">
{extraConfig}
<Form.Group controlId="excluded-fields">
<h6>
<FormattedMessage id="performer_tagger.config.excluded_fields" />
<FormattedMessage id="tagger.config.excluded_fields" />
</h6>
<span>
{excludedFields.length > 0 ? (
@ -55,17 +68,20 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</Badge>
))
) : (
<FormattedMessage id="performer_tagger.config.no_fields_are_excluded" />
<FormattedMessage id="tagger.config.no_fields_are_excluded" />
)}
</span>
<Form.Text>
<FormattedMessage id="performer_tagger.config.these_fields_will_not_be_changed_when_updating_performers" />
<FormattedMessage
id="tagger.config.fields_will_not_be_changed"
values={{ entity: entityName }}
/>
</Form.Text>
<Button
onClick={() => setShowExclusionModal(true)}
className="mt-2"
>
<FormattedMessage id="performer_tagger.config.edit_excluded_fields" />
<FormattedMessage id="tagger.config.edit_excluded_fields" />
</Button>
</Form.Group>
<Form.Group
@ -73,7 +89,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
className="align-items-center row no-gutters mt-4"
>
<Form.Label className="mr-4">
<FormattedMessage id="performer_tagger.config.active_stash-box_instance" />
<FormattedMessage id="tagger.config.active_stash-box_instance" />
</Form.Label>
<Form.Control
as="select"
@ -84,7 +100,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
>
{!stashBoxes.length && (
<option>
<FormattedMessage id="performer_tagger.config.no_instances_found" />
<FormattedMessage id="tagger.config.no_instances_found" />
</option>
)}
{stashConfig?.general.stashBoxes.map((i) => (
@ -98,8 +114,9 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</div>
</Card>
</Collapse>
<PerformerFieldSelector
<FieldSelector
show={showExclusionModal}
fields={fields}
onSelect={handleFieldSelect}
excludedFields={excludedFields}
/>
@ -107,4 +124,4 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
);
};
export default Config;
export default TaggerConfig;

View file

@ -24,6 +24,7 @@ export const DEFAULT_BLACKLIST = [
];
export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"];
export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"];
export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"];
export const initialConfig: ITaggerConfig = {
blacklist: DEFAULT_BLACKLIST,
@ -35,6 +36,7 @@ export const initialConfig: ITaggerConfig = {
excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS,
markSceneAsOrganizedOnSave: false,
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,
createParentStudios: true,
};
@ -52,6 +54,7 @@ export interface ITaggerConfig {
excludedPerformerFields?: string[];
markSceneAsOrganizedOnSave?: boolean;
excludedStudioFields?: string[];
excludedTagFields?: string[];
createParentStudios: boolean;
}
@ -82,3 +85,4 @@ export const PERFORMER_FIELDS = [
];
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];
export const TAG_FIELDS = ["name", "description", "aliases"];

View file

@ -19,8 +19,8 @@ import { Manual } from "src/components/Help/Manual";
import { useConfigurationContext } from "src/hooks/Config";
import StashSearchResult from "./StashSearchResult";
import PerformerConfig from "./Config";
import { ITaggerConfig } from "../constants";
import TaggerConfig from "../TaggerConfig";
import { ITaggerConfig, PERFORMER_FIELDS } from "../constants";
import PerformerModal from "../PerformerModal";
import { useUpdatePerformer } from "../queries";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
@ -771,10 +771,16 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
</Button>
</div>
<PerformerConfig
<TaggerConfig
config={config}
setConfig={setConfig}
show={showConfig}
excludedFields={config.excludedPerformerFields ?? []}
onFieldsChange={(fields) =>
setConfig({ ...config, excludedPerformerFields: fields })
}
fields={PERFORMER_FIELDS}
entityName="performers"
/>
<PerformerTaggerList
performers={performers}

View file

@ -97,3 +97,44 @@ export const useUpdateStudio = () => {
return updateStudioHandler;
};
export const useUpdateTag = () => {
const [updateTag] = GQL.useTagUpdateMutation({
onError: (errors) => errors,
errorPolicy: "all",
});
const updateTagHandler = (input: GQL.TagUpdateInput) =>
updateTag({
variables: {
input,
},
update: (store, updatedTag) => {
if (!updatedTag.data?.tagUpdate) return;
updatedTag.data.tagUpdate.stash_ids.forEach((id) => {
store.writeQuery<GQL.FindTagsQuery, GQL.FindTagsQueryVariables>({
query: GQL.FindTagsDocument,
variables: {
tag_filter: {
stash_id_endpoint: {
stash_id: id.stash_id,
endpoint: id.endpoint,
modifier: GQL.CriterionModifier.Equals,
},
},
},
data: {
findTags: {
count: 1,
tags: [updatedTag.data!.tagUpdate!],
__typename: "FindTagsResultType",
},
},
});
});
},
});
return updateTagHandler;
};

View file

@ -1,130 +0,0 @@
import React, { Dispatch, useState } from "react";
import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { useConfigurationContext } from "src/hooks/Config";
import { ITaggerConfig } from "../constants";
import StudioFieldSelector from "./StudioFieldSelector";
interface IConfigProps {
show: boolean;
config: ITaggerConfig;
setConfig: Dispatch<ITaggerConfig>;
}
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
const { configuration: stashConfig } = useConfigurationContext();
const [showExclusionModal, setShowExclusionModal] = useState(false);
const excludedFields = config.excludedStudioFields ?? [];
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedEndpoint = e.currentTarget.value;
setConfig({
...config,
selectedEndpoint,
});
};
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
const handleFieldSelect = (fields: string[]) => {
setConfig({ ...config, excludedStudioFields: fields });
setShowExclusionModal(false);
};
return (
<>
<Collapse in={show}>
<Card>
<div className="row">
<h4 className="col-12">
<FormattedMessage id="configuration" />
</h4>
<hr className="w-100" />
<div className="col-md-6">
<Form.Group
controlId="create-parent"
className="align-items-center"
>
<Form.Check
label={
<FormattedMessage id="studio_tagger.config.create_parent_label" />
}
checked={config.createParentStudios}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig({
...config,
createParentStudios: e.currentTarget.checked,
})
}
/>
<Form.Text>
<FormattedMessage id="studio_tagger.config.create_parent_desc" />
</Form.Text>
</Form.Group>
<Form.Group controlId="excluded-studio-fields">
<h6>
<FormattedMessage id="studio_tagger.config.excluded_fields" />
</h6>
<span>
{excludedFields.length > 0 ? (
excludedFields.map((f) => (
<Badge variant="secondary" className="tag-item" key={f}>
<FormattedMessage id={f} />
</Badge>
))
) : (
<FormattedMessage id="studio_tagger.config.no_fields_are_excluded" />
)}
</span>
<Form.Text>
<FormattedMessage id="studio_tagger.config.these_fields_will_not_be_changed_when_updating_studios" />
</Form.Text>
<Button
onClick={() => setShowExclusionModal(true)}
className="mt-2"
>
<FormattedMessage id="studio_tagger.config.edit_excluded_fields" />
</Button>
</Form.Group>
<Form.Group
controlId="stash-box-endpoint"
className="align-items-center row no-gutters mt-4"
>
<Form.Label className="mr-4">
<FormattedMessage id="studio_tagger.config.active_stash-box_instance" />
</Form.Label>
<Form.Control
as="select"
value={config.selectedEndpoint}
className="col-md-4 col-6 input-control"
disabled={!stashBoxes.length}
onChange={handleInstanceSelect}
>
{!stashBoxes.length && (
<option>
<FormattedMessage id="studio_tagger.config.no_instances_found" />
</option>
)}
{stashConfig?.general.stashBoxes.map((i) => (
<option value={i.endpoint} key={i.endpoint}>
{i.endpoint}
</option>
))}
</Form.Control>
</Form.Group>
</div>
</div>
</Card>
</Collapse>
<StudioFieldSelector
show={showExclusionModal}
onSelect={handleFieldSelect}
excludedFields={excludedFields}
/>
</>
);
};
export default Config;

View file

@ -1,68 +0,0 @@
import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";
import { Button, Row, Col } from "react-bootstrap";
import { useIntl } from "react-intl";
import { ModalComponent } from "../../Shared/Modal";
import { Icon } from "../../Shared/Icon";
import { STUDIO_FIELDS } from "../constants";
interface IProps {
show: boolean;
excludedFields: string[];
onSelect: (fields: string[]) => void;
}
const StudioFieldSelect: React.FC<IProps> = ({
show,
excludedFields,
onSelect,
}) => {
const intl = useIntl();
const [excluded, setExcluded] = useState<Record<string, boolean>>(
// filter out fields that aren't in STUDIO_FIELDS
excludedFields
.filter((field) => STUDIO_FIELDS.includes(field))
.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (field: string) =>
setExcluded({
...excluded,
[field]: !excluded[field],
});
const renderField = (field: string) => (
<Col xs={6} className="mb-1" key={field}>
<Button
onClick={() => toggleField(field)}
variant="secondary"
className={excluded[field] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[field] ? faTimes : faCheck} />
</Button>
<span className="ml-3">{intl.formatMessage({ id: field })}</span>
</Col>
);
return (
<ModalComponent
show={show}
icon={faList}
dialogClassName="FieldSelect"
accept={{
text: intl.formatMessage({ id: "actions.save" }),
onClick: () =>
onSelect(Object.keys(excluded).filter((f) => excluded[f])),
}}
>
<h4>Select tagged fields</h4>
<div className="mb-2">
These fields will be tagged by default. Click the button to toggle.
</div>
<Row>{STUDIO_FIELDS.map((f) => renderField(f))}</Row>
</ModalComponent>
);
};
export default StudioFieldSelect;

View file

@ -20,8 +20,8 @@ import { Manual } from "src/components/Help/Manual";
import { useConfigurationContext } from "src/hooks/Config";
import StashSearchResult from "./StashSearchResult";
import StudioConfig from "./Config";
import { ITaggerConfig } from "../constants";
import TaggerConfig from "../TaggerConfig";
import { ITaggerConfig, STUDIO_FIELDS } from "../constants";
import StudioModal from "../scenes/StudioModal";
import { useUpdateStudio } from "../queries";
import { apolloError } from "src/utils";
@ -825,10 +825,38 @@ export const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {
</Button>
</div>
<StudioConfig
<TaggerConfig
config={config}
setConfig={setConfig}
show={showConfig}
excludedFields={config.excludedStudioFields ?? []}
onFieldsChange={(fields) =>
setConfig({ ...config, excludedStudioFields: fields })
}
fields={STUDIO_FIELDS}
entityName="studios"
extraConfig={
<Form.Group
controlId="create-parent"
className="align-items-center"
>
<Form.Check
label={
<FormattedMessage id="studio_tagger.config.create_parent_label" />
}
checked={config.createParentStudios}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig({
...config,
createParentStudios: e.currentTarget.checked,
})
}
/>
<Form.Text>
<FormattedMessage id="studio_tagger.config.create_parent_desc" />
</Form.Text>
</Form.Group>
}
/>
<StudioTaggerList
studios={studios}

View file

@ -0,0 +1,119 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { useUpdateTag } from "../queries";
import TagModal from "./TagModal";
import { faTags } from "@fortawesome/free-solid-svg-icons";
import { useIntl } from "react-intl";
import { mergeTagStashIDs } from "../utils";
interface IStashSearchResultProps {
tag: GQL.TagListDataFragment;
stashboxTags: GQL.ScrapedSceneTagDataFragment[];
endpoint: string;
onTagTagged: (
tag: Pick<GQL.TagListDataFragment, "id"> &
Partial<Omit<GQL.TagListDataFragment, "id">>
) => void;
excludedTagFields: string[];
}
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
tag,
stashboxTags,
onTagTagged,
excludedTagFields,
endpoint,
}) => {
const intl = useIntl();
const [modalTag, setModalTag] = useState<GQL.ScrapedSceneTagDataFragment>();
const [saveState, setSaveState] = useState<string>("");
const [error, setError] = useState<{ message?: string; details?: string }>(
{}
);
const updateTag = useUpdateTag();
const handleSave = async (input: GQL.TagCreateInput) => {
setError({});
setModalTag(undefined);
setSaveState("Saving tag");
const updateData: GQL.TagUpdateInput = {
...input,
id: tag.id,
};
updateData.stash_ids = await mergeTagStashIDs(
tag.id,
input.stash_ids ?? []
);
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 ?? "",
});
} else {
onTagTagged(tag);
}
setSaveState("");
};
const tags = stashboxTags.map((p) => (
<Button
className="StudioTagger-studio-search-item minimal col-6"
variant="link"
key={p.remote_site_id}
onClick={() => setModalTag(p)}
>
<span>{p.name}</span>
</Button>
));
return (
<>
{modalTag && (
<TagModal
closeModal={() => setModalTag(undefined)}
modalVisible={modalTag !== undefined}
tag={modalTag}
onSave={handleSave}
icon={faTags}
header="Update Tag"
excludedTagFields={excludedTagFields}
endpoint={endpoint}
/>
)}
<div className="StudioTagger-studio-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">
<strong>
<span className="mr-2">Error:</span>
{error.message}
</strong>
<div>{error.details}</div>
</div>
)}
{saveState && (
<strong className="col-4 mt-1 mr-2 text-right">{saveState}</strong>
)}
</div>
</>
);
};
export default StashSearchResult;

View file

@ -0,0 +1,144 @@
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal";
import {
faCheck,
faExternalLinkAlt,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { Button } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { excludeFields } from "src/utils/data";
import { ExternalLink } from "src/components/Shared/ExternalLink";
interface ITagModalProps {
tag: GQL.ScrapedSceneTagDataFragment;
modalVisible: boolean;
closeModal: () => void;
onSave: (input: GQL.TagCreateInput) => void;
excludedTagFields?: string[];
header: string;
icon: IconDefinition;
endpoint?: string;
}
const TagModal: React.FC<ITagModalProps> = ({
modalVisible,
tag,
onSave,
closeModal,
excludedTagFields = [],
header,
icon,
endpoint,
}) => {
const intl = useIntl();
const [excluded, setExcluded] = useState<Record<string, boolean>>(
excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (name: string) =>
setExcluded({
...excluded,
[name]: !excluded[name],
});
function maybeRenderField(id: string, text: string | null | undefined) {
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>
<strong>
<FormattedMessage id={id} />:
</strong>
</div>
<TruncatedText className="col-7" text={text} />
</div>
);
}
function maybeRenderStashBoxLink() {
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
const link = base ? `${base}tags/${tag.remote_site_id}` : undefined;
if (!link) return;
return (
<h6 className="mt-2">
<ExternalLink href={link}>
<FormattedMessage id="stashbox.source" />
<Icon icon={faExternalLinkAlt} className="ml-2" />
</ExternalLink>
</h6>
);
}
function handleSave() {
if (!tag.name) {
throw new Error("tag name must be set");
}
const tagData: GQL.TagCreateInput = {
name: tag.name,
description: tag.description ?? undefined,
aliases: tag.alias_list?.filter((a) => a) ?? undefined,
};
// stashid handling code
const remoteSiteID = tag.remote_site_id;
if (remoteSiteID && endpoint) {
tagData.stash_ids = [
{
endpoint,
stash_id: remoteSiteID,
updated_at: new Date().toISOString(),
},
];
}
// handle exclusions
excludeFields(tagData, excluded);
onSave(tagData);
}
return (
<ModalComponent
show={modalVisible}
accept={{
text: intl.formatMessage({ id: "actions.save" }),
onClick: handleSave,
}}
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
onHide={() => closeModal()}
dialogClassName="studio-create-modal"
icon={icon}
header={header}
>
<div>
<div className="row">
<div className="col-12">
{maybeRenderField("name", tag.name)}
{maybeRenderField("description", tag.description)}
{maybeRenderField("aliases", tag.alias_list?.join(", "))}
{maybeRenderStashBoxLink()}
</div>
</div>
</div>
</ModalComponent>
);
};
export default TagModal;

View file

@ -0,0 +1,758 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom";
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,
mutateStashBoxBatchTagTag,
getClient,
} from "src/core/StashService";
import { Manual } from "src/components/Help/Manual";
import { useConfigurationContext } from "src/hooks/Config";
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";
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>
);
};
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;
}
const TagTaggerList: React.FC<ITagTaggerListProps> = ({
tags,
selectedEndpoint,
isIdle,
config,
onBatchAdd,
onBatchUpdate,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);
const [searchResults, setSearchResults] = useState<
Record<string, GQL.ScrapedSceneTagDataFragment[]>
>({});
const [searchErrors, setSearchErrors] = useState<
Record<string, string | undefined>
>({});
const [taggedTags, setTaggedTags] = useState<
Record<string, Partial<GQL.TagListDataFragment>>
>({});
const [queries, setQueries] = useState<Record<string, string>>({});
const [showBatchAdd, setShowBatchAdd] = useState(false);
const [showBatchUpdate, setShowBatchUpdate] = useState(false);
const [error, setError] = useState<
Record<string, { message?: string; details?: string } | undefined>
>({});
const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();
const doBoxSearch = (tagID: string, searchVal: string) => {
stashBoxTagQuery(searchVal, selectedEndpoint.endpoint)
.then((queryData) => {
const s = queryData.data?.scrapeSingleTag ?? [];
setSearchResults({
...searchResults,
[tagID]: s,
});
setSearchErrors({
...searchErrors,
[tagID]: undefined,
});
setLoading(false);
})
.catch(() => {
setLoading(false);
const { [tagID]: unassign, ...results } = searchResults;
setSearchResults(results);
setSearchErrors({
...searchErrors,
[tagID]: intl.formatMessage({
id: "tag_tagger.network_error",
}),
});
});
setLoading(true);
};
const updateTag = useUpdateTag();
const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => {
setLoadingUpdate(stashID);
setError({
...error,
[tagID]: undefined,
});
stashBoxTagQuery(stashID, endpoint)
.then(async (queryData) => {
const data = queryData.data?.scrapeSingleTag ?? [];
if (data.length > 0) {
const stashboxTag = data[0];
const updateData: GQL.TagUpdateInput = {
id: tagID,
};
if (
!(config.excludedTagFields ?? []).includes("name") &&
stashboxTag.name
) {
updateData.name = stashboxTag.name;
}
if (
stashboxTag.description &&
!(config.excludedTagFields ?? []).includes("description")
) {
updateData.description = stashboxTag.description;
}
if (
stashboxTag.alias_list &&
stashboxTag.alias_list.length > 0 &&
!(config.excludedTagFields ?? []).includes("aliases")
) {
updateData.aliases = stashboxTag.alias_list;
}
if (stashboxTag.remote_site_id) {
updateData.stash_ids = await mergeTagStashIDs(tagID, [
{
endpoint,
stash_id: stashboxTag.remote_site_id,
},
]);
}
const res = await updateTag(updateData);
if (!res?.data?.tagUpdate) {
setError({
...error,
[tagID]: {
message: `Failed to update tag`,
details: res?.errors?.[0]?.message ?? "",
},
});
}
}
})
.finally(() => setLoadingUpdate(undefined));
};
async function handleBatchAdd(input: string) {
onBatchAdd(input);
setShowBatchAdd(false);
}
const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {
onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh);
setShowBatchUpdate(false);
};
const handleTaggedTag = (
tag: Pick<GQL.TagListDataFragment, "id"> &
Partial<Omit<GQL.TagListDataFragment, "id">>
) => {
setTaggedTags({
...taggedTags,
[tag.id]: tag,
});
};
const renderTags = () =>
tags.map((tag) => {
const isTagged = taggedTags[tag.id];
const stashID = tag.stash_ids.find((s) => {
return s.endpoint === selectedEndpoint.endpoint;
});
let mainContent;
if (!isTagged && stashID !== undefined) {
mainContent = (
<div className="text-left">
<h5 className="text-bold">
<FormattedMessage id="tag_tagger.tag_already_tagged" />
</h5>
</div>
);
} else if (!isTagged && !stashID) {
mainContent = (
<InputGroup>
<Form.Control
className="text-input"
defaultValue={tag.name ?? ""}
onChange={(e) =>
setQueries({
...queries,
[tag.id]: e.currentTarget.value,
})
}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
e.key === "Enter" &&
doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "")
}
/>
<InputGroup.Append>
<Button
disabled={loading}
onClick={() =>
doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "")
}
>
<FormattedMessage id="actions.search" />
</Button>
</InputGroup.Append>
</InputGroup>
);
} else if (isTagged) {
mainContent = (
<div className="d-flex flex-column text-left">
<h5>
<FormattedMessage id="tag_tagger.tag_successfully_tagged" />
</h5>
</div>
);
}
let subContent;
if (stashID !== undefined) {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<ExternalLink
className="small d-block"
href={`${base}tags/${stashID.stash_id}`}
>
{stashID.stash_id}
</ExternalLink>
) : (
<div className="small">{stashID.stash_id}</div>
);
subContent = (
<div key={tag.id}>
<InputGroup className="StudioTagger-box-link">
<InputGroup.Text>{link}</InputGroup.Text>
<InputGroup.Append>
<Button
onClick={() =>
doBoxUpdate(tag.id, stashID.stash_id, stashID.endpoint)
}
disabled={!!loadingUpdate}
>
{loadingUpdate === stashID.stash_id ? (
<LoadingIndicator inline small message="" />
) : (
<FormattedMessage id="actions.refresh" />
)}
</Button>
</InputGroup.Append>
</InputGroup>
{error[tag.id] && (
<div className="text-danger mt-1">
<strong>
<span className="mr-2">Error:</span>
{error[tag.id]?.message}
</strong>
<div>{error[tag.id]?.details}</div>
</div>
)}
</div>
);
} else if (searchErrors[tag.id]) {
subContent = (
<div className="text-danger font-weight-bold">
{searchErrors[tag.id]}
</div>
);
} else if (searchResults[tag.id]?.length === 0) {
subContent = (
<div className="text-danger font-weight-bold">
<FormattedMessage id="tag_tagger.no_results_found" />
</div>
);
}
let searchResult;
if (searchResults[tag.id]?.length > 0 && !isTagged) {
searchResult = (
<StashSearchResult
key={tag.id}
stashboxTags={searchResults[tag.id]}
tag={tag}
endpoint={selectedEndpoint.endpoint}
onTagTagged={handleTaggedTag}
excludedTagFields={config.excludedTagFields ?? []}
/>
);
}
return (
<div key={tag.id} className={`${CLASSNAME}-studio`}>
<div className={`${CLASSNAME}-details`}>
<div></div>
<div>
<Card className="studio-card">
<img loading="lazy" src={tag.image_path ?? ""} alt="" />
</Card>
</div>
<div className={`${CLASSNAME}-details-text`}>
<Link to={`/tags/${tag.id}`} className={`${CLASSNAME}-header`}>
<h2>{tag.name}</h2>
</Link>
{mainContent}
<div className="sub-content text-left">{subContent}</div>
{searchResult}
</div>
</div>
</div>
);
});
return (
<Card>
{showBatchUpdate && (
<TagBatchUpdateModal
close={() => setShowBatchUpdate(false)}
isIdle={isIdle}
selectedEndpoint={selectedEndpoint}
tags={tags}
onBatchUpdate={handleBatchUpdate}
/>
)}
{showBatchAdd && (
<TagBatchAddModal
close={() => setShowBatchAdd(false)}
isIdle={isIdle}
onBatchAdd={handleBatchAdd}
/>
)}
<div className="ml-auto mb-3">
<Button onClick={() => setShowBatchAdd(true)}>
<FormattedMessage id="tag_tagger.batch_add_tags" />
</Button>
<Button className="ml-3" onClick={() => setShowBatchUpdate(true)}>
<FormattedMessage id="tag_tagger.batch_update_tags" />
</Button>
</div>
<div className={CLASSNAME}>{renderTags()}</div>
</Card>
);
};
interface ITaggerProps {
tags: GQL.TagListDataFragment[];
}
export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
const jobsSubscribe = useJobsSubscribe();
const intl = useIntl();
const { configuration: stashConfig } = useConfigurationContext();
const { config, setConfig } = useTaggerConfig();
const [showConfig, setShowConfig] = useState(false);
const [showManual, setShowManual] = useState(false);
const [batchJobID, setBatchJobID] = useState<string | undefined | null>();
const [batchJob, setBatchJob] = useState<JobFragment | undefined>();
useEffect(() => {
if (!jobsSubscribe.data) {
return;
}
const event = jobsSubscribe.data.jobsSubscribe;
if (event.job.id !== batchJobID) {
return;
}
if (event.type !== GQL.JobStatusUpdateType.Remove) {
setBatchJob(event.job);
} else {
setBatchJob(undefined);
setBatchJobID(undefined);
const ac = getClient();
ac.cache.evict({ fieldName: "findTags" });
ac.cache.gc();
}
}, [jobsSubscribe, batchJobID]);
if (!config) return <LoadingIndicator />;
const savedEndpointIndex =
stashConfig?.general.stashBoxes.findIndex(
(s) => s.endpoint === config.selectedEndpoint
) ?? -1;
const selectedEndpointIndex =
savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length
? 0
: savedEndpointIndex;
const selectedEndpoint =
stashConfig?.general.stashBoxes[selectedEndpointIndex];
async function batchAdd(tagInput: string) {
if (tagInput && selectedEndpoint) {
const inputs = tagInput
.split(",")
.map((n) => n.trim())
.filter((n) => n.length > 0);
const { names, stashIds } = separateNamesAndStashIds(inputs);
if (names.length > 0 || stashIds.length > 0) {
const ret = await mutateStashBoxBatchTagTag({
names: names.length > 0 ? names : undefined,
stash_ids: stashIds.length > 0 ? stashIds : undefined,
endpoint: selectedEndpointIndex,
refresh: false,
createParent: false,
exclude_fields: config?.excludedTagFields ?? [],
});
setBatchJobID(ret.data?.stashBoxBatchTagTag);
}
}
}
async function batchUpdate(ids: string[] | undefined, refresh: boolean) {
if (selectedEndpoint) {
const ret = await mutateStashBoxBatchTagTag({
ids: ids,
endpoint: selectedEndpointIndex,
refresh,
createParent: false,
exclude_fields: config?.excludedTagFields ?? [],
});
setBatchJobID(ret.data?.stashBoxBatchTagTag);
}
}
function renderStatus() {
if (batchJob) {
const progress =
batchJob.progress !== undefined && batchJob.progress !== null
? batchJob.progress * 100
: undefined;
return (
<Form.Group className="px-4">
<h5>
<FormattedMessage id="tag_tagger.status_tagging_tags" />
</h5>
{progress !== undefined && (
<ProgressBar
animated
now={progress}
label={`${progress.toFixed(0)}%`}
/>
)}
</Form.Group>
);
}
if (batchJobID !== undefined) {
return (
<Form.Group className="px-4">
<h5>
<FormattedMessage id="tag_tagger.status_tagging_job_queued" />
</h5>
</Form.Group>
);
}
}
const showHideConfigId = showConfig
? "actions.hide_configuration"
: "actions.show_configuration";
return (
<>
<Manual
show={showManual}
onClose={() => setShowManual(false)}
defaultActiveTab="Tagger.md"
/>
{renderStatus()}
<div className="tagger-container mx-md-auto">
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
<>
<div className="row mb-2 no-gutters">
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
{intl.formatMessage({ id: showHideConfigId })}
</Button>
<Button
className="ml-auto"
onClick={() => setShowManual(true)}
title={intl.formatMessage({ id: "help" })}
variant="link"
>
<FormattedMessage id="help" />
</Button>
</div>
<TaggerConfig
config={config}
setConfig={setConfig}
show={showConfig}
excludedFields={config.excludedTagFields ?? []}
onFieldsChange={(fields) =>
setConfig({ ...config, excludedTagFields: fields })
}
fields={TAG_FIELDS}
entityName="tags"
/>
<TagTaggerList
tags={tags}
selectedEndpoint={{
endpoint: selectedEndpoint.endpoint,
index: selectedEndpointIndex,
}}
isIdle={batchJobID === undefined}
config={config}
onBatchAdd={batchAdd}
onBatchUpdate={batchUpdate}
/>
</>
) : (
<div className="my-4">
<h3 className="text-center mt-4">
<FormattedMessage id="tag_tagger.to_use_the_tag_tagger" />
</h3>
<h5 className="text-center">
Please see{" "}
<HashLink
to="/settings?tab=metadata-providers#stash-boxes"
scroll={(el) =>
el.scrollIntoView({ behavior: "smooth", block: "center" })
}
>
Settings.
</HashLink>
</h5>
</div>
)}
</div>
</>
);
};

View file

@ -1,6 +1,6 @@
import * as GQL from "src/core/generated-graphql";
import { ParseMode } from "./constants";
import { queryFindStudio } from "src/core/StashService";
import { queryFindStudio, queryFindTag } from "src/core/StashService";
import { mergeStashIDs } from "src/utils/stashbox";
const months = [
@ -173,14 +173,32 @@ export const parsePath = (filePath: string) => {
return { paths, file, ext };
};
export async function mergeStudioStashIDs(
async function mergeEntityStashIDs(
fetchExisting: (id: string) => Promise<GQL.StashIdInput[] | undefined>,
id: string,
newStashIDs: GQL.StashIdInput[]
) {
const existing = await queryFindStudio(id);
if (existing?.data?.findStudio?.stash_ids) {
return mergeStashIDs(existing.data.findStudio.stash_ids, newStashIDs);
const existing = await fetchExisting(id);
if (existing) {
return mergeStashIDs(existing, newStashIDs);
}
return newStashIDs;
}
export const mergeStudioStashIDs = (
id: string,
newStashIDs: GQL.StashIdInput[]
) =>
mergeEntityStashIDs(
async (studioId) =>
(await queryFindStudio(studioId))?.data?.findStudio?.stash_ids,
id,
newStashIDs
);
export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) =>
mergeEntityStashIDs(
async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids,
id,
newStashIDs
);

View file

@ -30,6 +30,7 @@ import { EditTagsDialog } from "./EditTagsDialog";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { PatchComponent } from "src/patch";
import { TagTagger } from "../Tagger/tags/TagTagger";
function getItems(result: GQL.FindTagsForListQueryResult) {
return result?.data?.findTags?.tags ?? [];
@ -355,6 +356,9 @@ export const TagList: React.FC<ITagList> = PatchComponent(
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
if (filter.displayMode === DisplayMode.Tagger) {
return <TagTagger tags={result.data.findTags.tags} />;
}
}
return (
<>

View file

@ -2463,6 +2463,12 @@ export const mutateStashBoxBatchStudioTag = (
variables: { input },
});
export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) =>
client.mutate<GQL.StashBoxBatchTagTagMutation>({
mutation: GQL.StashBoxBatchTagTagDocument,
variables: { input },
});
export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery();
export const queryScrapeGroupURL = (url: string) =>

View file

@ -1345,14 +1345,6 @@
"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",
"config": {
"active_stash-box_instance": "Active stash-box instance:",
"edit_excluded_fields": "Edit Excluded Fields",
"excluded_fields": "Excluded fields:",
"no_fields_are_excluded": "No fields are excluded",
"no_instances_found": "No instances found",
"these_fields_will_not_be_changed_when_updating_performers": "These fields will not be changed when updating performers."
},
"current_page": "Current page",
"failed_to_save_performer": "Failed to save performer \"{performer}\"",
"name_already_exists": "Name already exists",
@ -1555,14 +1547,8 @@
"batch_add_studios": "Batch Add Studios",
"batch_update_studios": "Batch Update Studios",
"config": {
"active_stash-box_instance": "Active stash-box instance:",
"create_parent_desc": "Create missing parent studios, or tag and update data/image for existing parent studios with exact name matches",
"create_parent_label": "Create parent studios",
"edit_excluded_fields": "Edit Excluded Fields",
"excluded_fields": "Excluded fields:",
"no_fields_are_excluded": "No fields are excluded",
"no_instances_found": "No instances found",
"these_fields_will_not_be_changed_when_updating_studios": "These fields will not be changed when updating studios."
"create_parent_label": "Create parent studios"
},
"create_or_tag_parent_studios": "Create missing or tag existing parent studios",
"current_page": "Current page",
@ -1604,6 +1590,42 @@
"tag_count": "Tag Count",
"tag_parent_tooltip": "Has parent tags",
"tag_sub_tag_tooltip": "Has sub-tags",
"tag_tagger": {
"add_new_tags": "Add New Tags",
"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",
"current_page": "Current page",
"failed_to_save_tag": "Failed to save tag \"{tag}\"",
"name_already_exists": "Name already exists",
"network_error": "Network Error",
"no_results_found": "No results found.",
"number_of_tags_will_be_processed": "{tag_count} tags will be processed",
"query_all_tags_in_the_database": "All tags in the database",
"refresh_tagged_tags": "Refresh tagged tags",
"refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.",
"status_tagging_job_queued": "Status: Tagging job queued",
"status_tagging_tags": "Status: Tagging tags",
"tag_already_tagged": "Tag already tagged",
"tag_names_or_stashids_separated_by_comma": "Tag names or StashIDs separated by comma",
"tag_selection": "Tag selection",
"tag_successfully_tagged": "Tag successfully tagged",
"tag_status": "Tag Status",
"to_use_the_tag_tagger": "To use the tag tagger a stash-box instance needs to be configured.",
"untagged_tags": "Untagged tags",
"update_tags": "Update Tags",
"updating_untagged_tags_description": "Updating untagged tags will try to match any tags that lack a stashid and update the metadata."
},
"tagger": {
"config": {
"active_stash-box_instance": "Active stash-box instance:",
"edit_excluded_fields": "Edit Excluded Fields",
"excluded_fields": "Excluded fields:",
"fields_will_not_be_changed": "These fields will not be changed when updating {entity}.",
"no_fields_are_excluded": "No fields are excluded",
"no_instances_found": "No instances found"
}
},
"tags": "Tags",
"tattoos": "Tattoos",
"time": "Time",

View file

@ -50,7 +50,11 @@ const sortByOptions = ["name", "random", "scenes_duration"]
},
]);
const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
const displayModeOptions = [
DisplayMode.Grid,
DisplayMode.List,
DisplayMode.Tagger,
];
const criterionOptions = [
FavoriteTagCriterionOption,
createMandatoryStringCriterionOption("name"),