mirror of
https://github.com/stashapp/stash.git
synced 2026-02-08 16:31:52 +01:00
feat: auto-remove duplicate aliases
This commit is contained in:
parent
5b3785f164
commit
8dcc6dbc80
14 changed files with 271 additions and 65 deletions
|
|
@ -43,7 +43,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
|
||||
newPerformer.Name = strings.TrimSpace(input.Name)
|
||||
newPerformer.Disambiguation = translator.string(input.Disambiguation)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList))
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
|
||||
newPerformer.Gender = input.Gender
|
||||
newPerformer.Ethnicity = translator.string(input.Ethnicity)
|
||||
newPerformer.Country = translator.string(input.Country)
|
||||
|
|
@ -348,6 +348,27 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
}
|
||||
}
|
||||
|
||||
if updatedPerformer.Aliases != nil {
|
||||
p, err := qb.Find(ctx, performerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p != nil {
|
||||
if err := p.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
|
||||
name := p.Name
|
||||
if updatedPerformer.Name.Set {
|
||||
name = updatedPerformer.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedPerformer.Aliases.Values = sanitized
|
||||
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||
newStudio.Favorite = translator.bool(input.Favorite)
|
||||
newStudio.Details = translator.string(input.Details)
|
||||
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
|
||||
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
|
||||
|
||||
var err error
|
||||
|
|
@ -167,6 +167,28 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Studio
|
||||
|
||||
if updatedStudio.Aliases != nil {
|
||||
s, err := qb.Find(ctx, studioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
if err := s.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
|
||||
name := s.Name
|
||||
if updatedStudio.Name.Set {
|
||||
name = updatedStudio.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedStudio.Aliases.Values = sanitized
|
||||
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
|||
|
||||
newTag.Name = strings.TrimSpace(input.Name)
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
|
|
@ -151,6 +151,28 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if updatedTag.Aliases != nil {
|
||||
t, err := qb.Find(ctx, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t != nil {
|
||||
if err := t.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newAliases := updatedTag.Aliases.Apply(t.Aliases.List())
|
||||
name := t.Name
|
||||
if updatedTag.Name.Set {
|
||||
name = updatedTag.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(newAliases, name)
|
||||
updatedTag.Aliases.Values = sanitized
|
||||
updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,6 +225,11 @@ func ValidateUpdateAliases(existing models.Performer, name models.OptionalString
|
|||
newName = name.Value
|
||||
}
|
||||
|
||||
// If aliases is nil, we're only changing the name - check existing aliases against new name
|
||||
if aliases == nil {
|
||||
return ValidateAliases(newName, existing.Aliases)
|
||||
}
|
||||
|
||||
newAliases := aliases.Apply(existing.Aliases.List())
|
||||
|
||||
return ValidateAliases(newName, models.NewRelatedStrings(newAliases))
|
||||
|
|
|
|||
|
|
@ -213,12 +213,12 @@ func TestValidateUpdateAliases(t *testing.T) {
|
|||
want error
|
||||
}{
|
||||
{"both unset", osUnset, nil, nil},
|
||||
{"invalid name set", os2, nil, &DuplicateAliasError{name2}},
|
||||
{"name conflicts with alias", os2, nil, &DuplicateAliasError{name2}},
|
||||
{"valid name set", os3, nil, nil},
|
||||
{"valid aliases empty", os1, []string{}, nil},
|
||||
{"invalid aliases set", osUnset, []string{name1U}, &DuplicateAliasError{name1U}},
|
||||
{"alias matches name", osUnset, []string{name1U}, &DuplicateAliasError{name1U}},
|
||||
{"valid aliases set", osUnset, []string{name3, name2}, nil},
|
||||
{"invalid both set", os4, []string{name4}, &DuplicateAliasError{name4}},
|
||||
{"alias matches new name", os4, []string{name4}, &DuplicateAliasError{name4}},
|
||||
{"valid both set", os2, []string{name1}, nil},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,23 @@ func UniqueFold(s []string) []string {
|
|||
return ret
|
||||
}
|
||||
|
||||
// UniqueExcludeFold returns a deduplicated slice of strings with the excluded string removed.
|
||||
// The comparison is case-insensitive.
|
||||
func UniqueExcludeFold(values []string, exclude string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
seen[strings.ToLower(exclude)] = struct{}{}
|
||||
ret := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
vLower := strings.ToLower(v)
|
||||
if _, exists := seen[vLower]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vLower] = struct{}{}
|
||||
ret = append(ret, v)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// TrimSpace trims whitespace from each string in a slice.
|
||||
func TrimSpace(s []string) []string {
|
||||
for i, v := range s {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModi
|
|||
}
|
||||
|
||||
effectiveAliases := s.Aliases.Apply(existing.Aliases.List())
|
||||
|
||||
if err := ValidateAliases(ctx, s.ID, effectiveAliases, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,3 +102,72 @@ func TestValidateUpdateName(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateAliases(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
const (
|
||||
name1 = "name 1"
|
||||
name2 = "name 2"
|
||||
alias1 = "alias 1"
|
||||
newAlias = "new alias"
|
||||
)
|
||||
|
||||
existing1 := models.Studio{
|
||||
ID: 1,
|
||||
Name: name1,
|
||||
}
|
||||
existing2 := models.Studio{
|
||||
ID: 2,
|
||||
Name: name2,
|
||||
}
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
aliasFilter := func(n string) *models.StudioFilterType {
|
||||
return &models.StudioFilterType{
|
||||
Aliases: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// name1 matches existing1 name - ok
|
||||
db.Studio.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
db.Studio.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
|
||||
// name2 matches existing2 name - error
|
||||
db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 1, nil)
|
||||
|
||||
// alias matches existing alias - error
|
||||
db.Studio.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)
|
||||
db.Studio.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Studio{&existing2}, 1, nil)
|
||||
|
||||
// valid alias
|
||||
db.Studio.On("Query", testCtx, nameFilter("valid"), findFilter).Return(nil, 0, nil)
|
||||
db.Studio.On("Query", testCtx, aliasFilter("valid"), findFilter).Return(nil, 0, nil)
|
||||
|
||||
tests := []struct {
|
||||
tName string
|
||||
studio models.Studio
|
||||
aliases []string
|
||||
want error
|
||||
}{
|
||||
{"valid alias", existing1, []string{alias1}, nil},
|
||||
{"alias duplicates other name", existing1, []string{name2}, &NameExistsError{name2}},
|
||||
{"alias duplicates other alias", existing1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},
|
||||
{"valid new alias", existing1, []string{"valid"}, nil},
|
||||
{"empty alias", existing1, []string{""}, ErrEmptyAlias},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tName, func(t *testing.T) {
|
||||
got := ValidateAliases(testCtx, tt.studio.ID, tt.aliases, db.Studio)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ func ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb m
|
|||
return err
|
||||
}
|
||||
|
||||
if err := EnsureAliasesUnique(ctx, id, partial.Aliases.Apply(existing.Aliases.List()), qb); err != nil {
|
||||
newAliases := partial.Aliases.Apply(existing.Aliases.List())
|
||||
|
||||
if err := EnsureAliasesUnique(ctx, id, newAliases, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
pkg/tag/validate_test.go
Normal file
86
pkg/tag/validate_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package tag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func nameFilter(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func aliasFilter(n string) *models.TagFilterType {
|
||||
return &models.TagFilterType{
|
||||
Aliases: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAliasesUnique(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
const (
|
||||
name1 = "name 1"
|
||||
name2 = "name 2"
|
||||
alias1 = "alias 1"
|
||||
newAlias = "new alias"
|
||||
)
|
||||
|
||||
existing2 := models.Tag{
|
||||
ID: 2,
|
||||
Name: name2,
|
||||
}
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
// name1 matches existing1 name - ok
|
||||
// EnsureAliasesUnique calls EnsureTagNameUnique.
|
||||
// EnsureTagNameUnique calls ByName then ByAlias.
|
||||
|
||||
// Case 1: valid alias
|
||||
// ByName "alias 1" -> nil
|
||||
// ByAlias "alias 1" -> nil
|
||||
db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)
|
||||
|
||||
// Case 2: alias duplicates existing2 name
|
||||
// ByName "name 2" -> existing2
|
||||
db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
|
||||
|
||||
// Case 3: alias duplicates existing2 alias
|
||||
// ByName "new alias" -> nil
|
||||
// ByAlias "new alias" -> existing2
|
||||
db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)
|
||||
db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
|
||||
|
||||
tests := []struct {
|
||||
tName string
|
||||
id int
|
||||
aliases []string
|
||||
want error
|
||||
}{
|
||||
{"valid alias", 1, []string{alias1}, nil},
|
||||
{"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}},
|
||||
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tName, func(t *testing.T) {
|
||||
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ import {
|
|||
yupInputNumber,
|
||||
yupInputEnum,
|
||||
yupDateString,
|
||||
yupUniqueAliases,
|
||||
yupRequiredStringArray,
|
||||
yupUniqueStringList,
|
||||
} from "src/utils/yup";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
|
@ -110,7 +110,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
disambiguation: yup.string().ensure(),
|
||||
alias_list: yupUniqueAliases(intl, "name"),
|
||||
alias_list: yupRequiredStringArray(intl).defined(),
|
||||
gender: yupInputEnum(GQL.GenderEnum).nullable().defined(),
|
||||
birthdate: yupDateString(intl),
|
||||
death_date: yupDateString(intl),
|
||||
|
|
@ -509,15 +509,15 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
))}
|
||||
{queryableScrapers
|
||||
? queryableScrapers.map((s) => (
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
key={s.name}
|
||||
className="minimal"
|
||||
onClick={() => onScraperSelected(s)}
|
||||
>
|
||||
{s.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
key={s.name}
|
||||
className="minimal"
|
||||
onClick={() => onScraperSelected(s)}
|
||||
>
|
||||
{s.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
: ""}
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useToast } from "src/hooks/Toast";
|
|||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
|
||||
import { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup";
|
||||
import { Studio, StudioSelect } from "../StudioSelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
|
@ -58,7 +58,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
urls: yup.array(yup.string().required()).defined(),
|
||||
details: yup.string().ensure(),
|
||||
parent_id: yup.string().required().nullable(),
|
||||
aliases: yupUniqueAliases(intl, "name"),
|
||||
aliases: yupRequiredStringArray(intl).defined(),
|
||||
tag_ids: yup.array(yup.string().required()).defined(),
|
||||
ignore_auto_tag: yup.boolean().defined(),
|
||||
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
|
||||
|
|
@ -103,10 +103,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
setParentStudio(
|
||||
studio.parent_studio
|
||||
? {
|
||||
id: studio.parent_studio.id,
|
||||
name: studio.parent_studio.name,
|
||||
aliases: [],
|
||||
}
|
||||
id: studio.parent_studio.id,
|
||||
name: studio.parent_studio.name,
|
||||
aliases: [],
|
||||
}
|
||||
: null
|
||||
);
|
||||
}, [studio.parent_studio]);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useToast } from "src/hooks/Toast";
|
|||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
|
||||
import { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup";
|
||||
import { addUpdateStashID, getStashIDs } from "src/utils/stashIds";
|
||||
import { Tag, TagSelect } from "../TagSelect";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
|
@ -56,7 +56,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
sort_name: yup.string().ensure(),
|
||||
aliases: yupUniqueAliases(intl, "name"),
|
||||
aliases: yupRequiredStringArray(intl).defined(),
|
||||
description: yup.string().ensure(),
|
||||
parent_ids: yup.array(yup.string().required()).defined(),
|
||||
child_ids: yup.array(yup.string().required()).defined(),
|
||||
|
|
|
|||
|
|
@ -92,45 +92,6 @@ export function yupUniqueStringList(intl: IntlShape) {
|
|||
});
|
||||
}
|
||||
|
||||
export function yupUniqueAliases(intl: IntlShape, nameField: string) {
|
||||
return yupRequiredStringArray(intl)
|
||||
.defined()
|
||||
.test({
|
||||
name: "unique",
|
||||
test(value) {
|
||||
const aliases = [this.parent[nameField].toLowerCase()];
|
||||
const dupes: number[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const s = value[i].toLowerCase();
|
||||
if (aliases.includes(s)) {
|
||||
dupes.push(i);
|
||||
} else {
|
||||
aliases.push(s);
|
||||
}
|
||||
}
|
||||
if (dupes.length === 0) return true;
|
||||
|
||||
const msg = yup.ValidationError.formatError(
|
||||
intl.formatMessage({ id: "validation.unique" }),
|
||||
{
|
||||
label: this.schema.spec.label,
|
||||
path: this.path,
|
||||
}
|
||||
);
|
||||
const errors = dupes.map(
|
||||
(i) =>
|
||||
new yup.ValidationError(
|
||||
msg,
|
||||
value[i],
|
||||
`${this.path}["${i}"]`,
|
||||
"unique"
|
||||
)
|
||||
);
|
||||
return new yup.ValidationError(errors, value, this.path, "unique");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function yupDateString(intl: IntlShape) {
|
||||
return yup
|
||||
.string()
|
||||
|
|
|
|||
Loading…
Reference in a new issue