Add Sort Name to Tags (#5531)

* override "name" sort with COALESCE
* tag sort_name frontend

adds `data-sort-name` attribute to tag links prioritizes sort_name value but will default to tag name if not present in the same way that COALESCE will prioritize the same values in the same way
* add sort_name filter, update locale per request

* Include sort name in anonymiser
* Add import/export support
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
stg-annon 2025-02-10 16:17:21 -05:00 committed by GitHub
parent dd40c07a6d
commit d2daf6c69f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 86 additions and 12 deletions

View file

@ -542,6 +542,9 @@ input TagFilterType {
"Filter by tag name" "Filter by tag name"
name: StringCriterionInput name: StringCriterionInput
"Filter by tag sort_name"
sort_name: StringCriterionInput
"Filter by tag aliases" "Filter by tag aliases"
aliases: StringCriterionInput aliases: StringCriterionInput

View file

@ -1,6 +1,8 @@
type Tag { type Tag {
id: ID! id: ID!
name: String! name: String!
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String description: String
aliases: [String!]! aliases: [String!]!
ignore_auto_tag: Boolean! ignore_auto_tag: Boolean!
@ -25,6 +27,8 @@ type Tag {
input TagCreateInput { input TagCreateInput {
name: String! name: String!
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String description: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
@ -39,6 +43,8 @@ input TagCreateInput {
input TagUpdateInput { input TagUpdateInput {
id: ID! id: ID!
name: String name: String
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String description: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean

View file

@ -33,6 +33,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
newTag := models.NewTag() newTag := models.NewTag()
newTag.Name = input.Name newTag.Name = input.Name
newTag.SortName = translator.string(input.SortName)
newTag.Aliases = models.NewRelatedStrings(input.Aliases) newTag.Aliases = models.NewRelatedStrings(input.Aliases)
newTag.Favorite = translator.bool(input.Favorite) newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description) newTag.Description = translator.string(input.Description)
@ -102,6 +103,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
updatedTag := models.NewTagPartial() updatedTag := models.NewTagPartial()
updatedTag.Name = translator.optionalString(input.Name, "name") updatedTag.Name = translator.optionalString(input.Name, "name")
updatedTag.SortName = translator.optionalString(input.SortName, "sort_name")
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedTag.Description = translator.optionalString(input.Description, "description") updatedTag.Description = translator.optionalString(input.Description, "description")

View file

@ -11,6 +11,7 @@ import (
type Tag struct { type Tag struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
SortName string `json:"sort_name,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Favorite bool `json:"favorite,omitempty"` Favorite bool `json:"favorite,omitempty"`
Aliases []string `json:"aliases,omitempty"` Aliases []string `json:"aliases,omitempty"`

View file

@ -8,6 +8,7 @@ import (
type Tag struct { type Tag struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
SortName string `json:"sort_name"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
Description string `json:"description"` Description string `json:"description"`
IgnoreAutoTag bool `json:"ignore_auto_tag"` IgnoreAutoTag bool `json:"ignore_auto_tag"`
@ -47,6 +48,7 @@ func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error {
type TagPartial struct { type TagPartial struct {
Name OptionalString Name OptionalString
SortName OptionalString
Description OptionalString Description OptionalString
Favorite OptionalBool Favorite OptionalBool
IgnoreAutoTag OptionalBool IgnoreAutoTag OptionalBool

View file

@ -4,6 +4,8 @@ type TagFilterType struct {
OperatorFilter[TagFilterType] OperatorFilter[TagFilterType]
// Filter by tag name // Filter by tag name
Name *StringCriterionInput `json:"name"` Name *StringCriterionInput `json:"name"`
// Filter by tag sort_name
SortName *StringCriterionInput `json:"sort_name"`
// Filter by tag aliases // Filter by tag aliases
Aliases *StringCriterionInput `json:"aliases"` Aliases *StringCriterionInput `json:"aliases"`
// Filter by tag favorites // Filter by tag favorites

View file

@ -816,6 +816,7 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
query := dialect.From(table).Select( query := dialect.From(table).Select(
table.Col(idColumn), table.Col(idColumn),
table.Col("name"), table.Col("name"),
table.Col("sort_name"),
table.Col("description"), table.Col("description"),
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
@ -826,12 +827,14 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
var ( var (
id int id int
name sql.NullString name sql.NullString
sortName sql.NullString
description sql.NullString description sql.NullString
) )
if err := rows.Scan( if err := rows.Scan(
&id, &id,
&name, &name,
&sortName,
&description, &description,
); err != nil { ); err != nil {
return err return err
@ -839,6 +842,7 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
set := goqu.Record{} set := goqu.Record{}
db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "name", name)
db.obfuscateNullString(set, "sort_name", sortName)
db.obfuscateNullString(set, "description", description) db.obfuscateNullString(set, "description", description)
if len(set) > 0 { if len(set) > 0 {

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
) )
var appSchemaVersion uint = 71 var appSchemaVersion uint = 72
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

@ -155,7 +155,7 @@ var (
}, },
fkColumn: "tag_id", fkColumn: "tag_id",
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
images: joinRepository{ images: joinRepository{
repository: repository{ repository: repository{

View file

@ -122,7 +122,7 @@ var (
}, },
fkColumn: tagIDColumn, fkColumn: tagIDColumn,
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
} }
) )

View file

@ -177,7 +177,7 @@ var (
}, },
fkColumn: tagIDColumn, fkColumn: tagIDColumn,
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
} }
) )

View file

@ -0,0 +1,2 @@
ALTER TABLE `tags` ADD COLUMN `sort_name` varchar(255);

View file

@ -189,7 +189,7 @@ var (
}, },
fkColumn: tagIDColumn, fkColumn: tagIDColumn,
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
stashIDs: stashIDRepository{ stashIDs: stashIDRepository{
repository{ repository{

View file

@ -201,7 +201,7 @@ var (
}, },
fkColumn: tagIDColumn, fkColumn: tagIDColumn,
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
performers: joinRepository{ performers: joinRepository{
repository: repository{ repository: repository{

View file

@ -133,7 +133,7 @@ var (
}, },
fkColumn: tagIDColumn, fkColumn: tagIDColumn,
foreignTable: tagTable, foreignTable: tagTable,
orderBy: "tags.name ASC", orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
}, },
} }
) )

View file

@ -33,6 +33,7 @@ const (
type tagRow struct { type tagRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name null.String `db:"name"` // TODO: make schema non-nullable Name null.String `db:"name"` // TODO: make schema non-nullable
SortName zero.String `db:"sort_name"`
Favorite bool `db:"favorite"` Favorite bool `db:"favorite"`
Description zero.String `db:"description"` Description zero.String `db:"description"`
IgnoreAutoTag bool `db:"ignore_auto_tag"` IgnoreAutoTag bool `db:"ignore_auto_tag"`
@ -46,6 +47,7 @@ type tagRow struct {
func (r *tagRow) fromTag(o models.Tag) { func (r *tagRow) fromTag(o models.Tag) {
r.ID = o.ID r.ID = o.ID
r.Name = null.StringFrom(o.Name) r.Name = null.StringFrom(o.Name)
r.SortName = zero.StringFrom((o.SortName))
r.Favorite = o.Favorite r.Favorite = o.Favorite
r.Description = zero.StringFrom(o.Description) r.Description = zero.StringFrom(o.Description)
r.IgnoreAutoTag = o.IgnoreAutoTag r.IgnoreAutoTag = o.IgnoreAutoTag
@ -57,6 +59,7 @@ func (r *tagRow) resolve() *models.Tag {
ret := &models.Tag{ ret := &models.Tag{
ID: r.ID, ID: r.ID,
Name: r.Name.String, Name: r.Name.String,
SortName: r.SortName.String,
Favorite: r.Favorite, Favorite: r.Favorite,
Description: r.Description.String, Description: r.Description.String,
IgnoreAutoTag: r.IgnoreAutoTag, IgnoreAutoTag: r.IgnoreAutoTag,
@ -87,6 +90,7 @@ type tagRowRecord struct {
func (r *tagRowRecord) fromPartial(o models.TagPartial) { func (r *tagRowRecord) fromPartial(o models.TagPartial) {
r.setString("name", o.Name) r.setString("name", o.Name)
r.setNullString("sort_name", o.SortName)
r.setNullString("description", o.Description) r.setNullString("description", o.Description)
r.setBool("favorite", o.Favorite) r.setBool("favorite", o.Favorite)
r.setBool("ignore_auto_tag", o.IgnoreAutoTag) r.setBool("ignore_auto_tag", o.IgnoreAutoTag)
@ -672,6 +676,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
sortQuery := "" sortQuery := ""
switch sort { switch sort {
case "name":
sortQuery += fmt.Sprintf(" ORDER BY COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI %s", getSortDirection(direction))
case "scenes_count": case "scenes_count":
sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction)
case "scene_markers_count": case "scene_markers_count":
@ -690,8 +696,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
sortQuery += getSort(sort, direction, "tags") sortQuery += getSort(sort, direction, "tags")
} }
// Whatever the sorting, always use name/id as a final sort // Whatever the sorting, always use sort_name/name/id as a final sort
sortQuery += ", COALESCE(tags.name, tags.id) COLLATE NATURAL_CI ASC" sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC"
return sortQuery, nil return sortQuery, nil
} }

View file

@ -62,6 +62,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
tagFilter := qb.tagFilter tagFilter := qb.tagFilter
return compoundHandler{ return compoundHandler{
stringCriterionHandler(tagFilter.Name, tagTable+".name"), stringCriterionHandler(tagFilter.Name, tagTable+".name"),
stringCriterionHandler(tagFilter.SortName, tagTable+".sort_name"),
qb.aliasCriterionHandler(tagFilter.Aliases), qb.aliasCriterionHandler(tagFilter.Aliases),
boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil), boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil),

View file

@ -21,6 +21,7 @@ type FinderAliasImageGetter interface {
func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) { func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) {
newTagJSON := jsonschema.Tag{ newTagJSON := jsonschema.Tag{
Name: tag.Name, Name: tag.Name,
SortName: tag.SortName,
Description: tag.Description, Description: tag.Description,
Favorite: tag.Favorite, Favorite: tag.Favorite,
IgnoreAutoTag: tag.IgnoreAutoTag, IgnoreAutoTag: tag.IgnoreAutoTag,

View file

@ -24,6 +24,7 @@ const (
const ( const (
tagName = "testTag" tagName = "testTag"
sortName = "sortName"
description = "description" description = "description"
) )
@ -37,6 +38,7 @@ func createTag(id int) models.Tag {
return models.Tag{ return models.Tag{
ID: id, ID: id,
Name: tagName, Name: tagName,
SortName: sortName,
Favorite: true, Favorite: true,
Description: description, Description: description,
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,
@ -48,6 +50,7 @@ func createTag(id int) models.Tag {
func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag { func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag {
return &jsonschema.Tag{ return &jsonschema.Tag{
Name: tagName, Name: tagName,
SortName: sortName,
Favorite: true, Favorite: true,
Description: description, Description: description,
Aliases: aliases, Aliases: aliases,

View file

@ -38,6 +38,7 @@ type Importer struct {
func (i *Importer) PreImport(ctx context.Context) error { func (i *Importer) PreImport(ctx context.Context) error {
i.tag = models.Tag{ i.tag = models.Tag{
Name: i.Input.Name, Name: i.Input.Name,
SortName: i.Input.SortName,
Description: i.Input.Description, Description: i.Input.Description,
Favorite: i.Input.Favorite, Favorite: i.Input.Favorite,
IgnoreAutoTag: i.Input.IgnoreAutoTag, IgnoreAutoTag: i.Input.IgnoreAutoTag,

View file

@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) {
i := Importer{ i := Importer{
Input: jsonschema.Tag{ Input: jsonschema.Tag{
Name: tagName, Name: tagName,
SortName: sortName,
Description: description, Description: description,
Image: invalidImage, Image: invalidImage,
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,

View file

@ -1,6 +1,7 @@
fragment SlimTagData on Tag { fragment SlimTagData on Tag {
id id
name name
sort_name
aliases aliases
image_path image_path
parent_count parent_count

View file

@ -1,6 +1,7 @@
fragment TagData on Tag { fragment TagData on Tag {
id id
name name
sort_name
description description
aliases aliases
ignore_auto_tag ignore_auto_tag
@ -33,6 +34,7 @@ fragment TagData on Tag {
fragment SelectTagData on Tag { fragment SelectTagData on Tag {
id id
name name
sort_name
favorite favorite
description description
aliases aliases
@ -41,5 +43,6 @@ fragment SelectTagData on Tag {
parents { parents {
id id
name name
sort_name
} }
} }

View file

@ -19,6 +19,30 @@ type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & {
primary_tag: Pick<GQL.Tag, "id" | "name">; primary_tag: Pick<GQL.Tag, "id" | "name">;
}; };
interface ISortNameLinkProps {
link: string;
className?: string;
sortName?: string;
}
const SortNameLinkComponent: React.FC<ISortNameLinkProps> = ({
link,
sortName,
className,
children,
}) => {
return (
<Badge
data-name={className}
data-sort-name={sortName}
className={cx("tag-item", className)}
variant="secondary"
>
<Link to={link}>{children}</Link>
</Badge>
);
};
interface ICommonLinkProps { interface ICommonLinkProps {
link: string; link: string;
className?: string; className?: string;
@ -263,7 +287,11 @@ export const TagLink: React.FC<ITagLinkProps> = ({
}, [hierarchyTooltipID]); }, [hierarchyTooltipID]);
return ( return (
<CommonLinkComponent link={link} className={className}> <SortNameLinkComponent
sortName={tag.sort_name || title}
link={link}
className={className}
>
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}> <TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
{title} {title}
{showHierarchyIcon && ( {showHierarchyIcon && (
@ -275,6 +303,6 @@ export const TagLink: React.FC<ITagLinkProps> = ({
</OverlayTrigger> </OverlayTrigger>
)} )}
</TagPopover> </TagPopover>
</CommonLinkComponent> </SortNameLinkComponent>
); );
}; };

View file

@ -46,6 +46,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
sort_name: yup.string().ensure(),
aliases: yupUniqueAliases(intl, "name"), aliases: yupUniqueAliases(intl, "name"),
description: yup.string().ensure(), description: yup.string().ensure(),
parent_ids: yup.array(yup.string().required()).defined(), parent_ids: yup.array(yup.string().required()).defined(),
@ -56,6 +57,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const initialValues = { const initialValues = {
name: tag?.name ?? "", name: tag?.name ?? "",
sort_name: tag?.sort_name ?? "",
aliases: tag?.aliases ?? [], aliases: tag?.aliases ?? [],
description: tag?.description ?? "", description: tag?.description ?? "",
parent_ids: (tag?.parents ?? []).map((t) => t.id), parent_ids: (tag?.parents ?? []).map((t) => t.id),
@ -203,6 +205,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
{renderInputField("name")} {renderInputField("name")}
{renderInputField("sort_name", "text")}
{renderStringListField("aliases")} {renderStringListField("aliases")}
{renderInputField("description", "textarea")} {renderInputField("description", "textarea")}
{renderParentTagsField()} {renderParentTagsField()}

View file

@ -1159,6 +1159,7 @@
"megabits_per_second": "{value} mbps", "megabits_per_second": "{value} mbps",
"metadata": "Metadata", "metadata": "Metadata",
"name": "Name", "name": "Name",
"sort_name": "Sort Name",
"new": "New", "new": "New",
"none": "None", "none": "None",
"o_count": "O Count", "o_count": "O Count",

View file

@ -53,6 +53,7 @@ const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
const criterionOptions = [ const criterionOptions = [
FavoriteTagCriterionOption, FavoriteTagCriterionOption,
createMandatoryStringCriterionOption("name"), createMandatoryStringCriterionOption("name"),
createStringCriterionOption("sort_name"),
TagIsMissingCriterionOption, TagIsMissingCriterionOption,
createStringCriterionOption("aliases"), createStringCriterionOption("aliases"),
createStringCriterionOption("description"), createStringCriterionOption("description"),

View file

@ -219,4 +219,5 @@ export type CriterionType =
| "code" | "code"
| "photographer" | "photographer"
| "disambiguation" | "disambiguation"
| "has_chapters"; | "has_chapters"
| "sort_name";

View file

@ -87,6 +87,7 @@ const makePerformerImagesUrl = (
export interface INamedObject { export interface INamedObject {
id: string; id: string;
name?: string; name?: string;
sort_name?: string | null;
} }
const makePerformerGalleriesUrl = ( const makePerformerGalleriesUrl = (