mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
dd40c07a6d
commit
d2daf6c69f
29 changed files with 86 additions and 12 deletions
|
|
@ -542,6 +542,9 @@ input TagFilterType {
|
|||
"Filter by tag name"
|
||||
name: StringCriterionInput
|
||||
|
||||
"Filter by tag sort_name"
|
||||
sort_name: StringCriterionInput
|
||||
|
||||
"Filter by tag aliases"
|
||||
aliases: StringCriterionInput
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
aliases: [String!]!
|
||||
ignore_auto_tag: Boolean!
|
||||
|
|
@ -25,6 +27,8 @@ type Tag {
|
|||
|
||||
input TagCreateInput {
|
||||
name: String!
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
|
@ -39,6 +43,8 @@ input TagCreateInput {
|
|||
input TagUpdateInput {
|
||||
id: ID!
|
||||
name: String
|
||||
"Value that does not appear in the UI but overrides name for sorting"
|
||||
sort_name: String
|
||||
description: String
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
|||
newTag := models.NewTag()
|
||||
|
||||
newTag.Name = input.Name
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
|
|
@ -102,6 +103,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
updatedTag := models.NewTagPartial()
|
||||
|
||||
updatedTag.Name = translator.optionalString(input.Name, "name")
|
||||
updatedTag.SortName = translator.optionalString(input.SortName, "sort_name")
|
||||
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedTag.Description = translator.optionalString(input.Description, "description")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
type Tag struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
SortName string `json:"sort_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
type Tag struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortName string `json:"sort_name"`
|
||||
Favorite bool `json:"favorite"`
|
||||
Description string `json:"description"`
|
||||
IgnoreAutoTag bool `json:"ignore_auto_tag"`
|
||||
|
|
@ -47,6 +48,7 @@ func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error {
|
|||
|
||||
type TagPartial struct {
|
||||
Name OptionalString
|
||||
SortName OptionalString
|
||||
Description OptionalString
|
||||
Favorite OptionalBool
|
||||
IgnoreAutoTag OptionalBool
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ type TagFilterType struct {
|
|||
OperatorFilter[TagFilterType]
|
||||
// Filter by tag name
|
||||
Name *StringCriterionInput `json:"name"`
|
||||
// Filter by tag sort_name
|
||||
SortName *StringCriterionInput `json:"sort_name"`
|
||||
// Filter by tag aliases
|
||||
Aliases *StringCriterionInput `json:"aliases"`
|
||||
// Filter by tag favorites
|
||||
|
|
|
|||
|
|
@ -816,6 +816,7 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
|
|||
query := dialect.From(table).Select(
|
||||
table.Col(idColumn),
|
||||
table.Col("name"),
|
||||
table.Col("sort_name"),
|
||||
table.Col("description"),
|
||||
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
|
||||
|
||||
|
|
@ -826,12 +827,14 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
|
|||
var (
|
||||
id int
|
||||
name sql.NullString
|
||||
sortName sql.NullString
|
||||
description sql.NullString
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&name,
|
||||
&sortName,
|
||||
&description,
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -839,6 +842,7 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error {
|
|||
|
||||
set := goqu.Record{}
|
||||
db.obfuscateNullString(set, "name", name)
|
||||
db.obfuscateNullString(set, "sort_name", sortName)
|
||||
db.obfuscateNullString(set, "description", description)
|
||||
|
||||
if len(set) > 0 {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const (
|
|||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 71
|
||||
var appSchemaVersion uint = 72
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ var (
|
|||
},
|
||||
fkColumn: "tag_id",
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
images: joinRepository{
|
||||
repository: repository{
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ var (
|
|||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ var (
|
|||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
2
pkg/sqlite/migrations/72_tag_sort_name.up.sql
Normal file
2
pkg/sqlite/migrations/72_tag_sort_name.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `tags` ADD COLUMN `sort_name` varchar(255);
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ var (
|
|||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
stashIDs: stashIDRepository{
|
||||
repository{
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ var (
|
|||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
performers: joinRepository{
|
||||
repository: repository{
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ var (
|
|||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const (
|
|||
type tagRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name null.String `db:"name"` // TODO: make schema non-nullable
|
||||
SortName zero.String `db:"sort_name"`
|
||||
Favorite bool `db:"favorite"`
|
||||
Description zero.String `db:"description"`
|
||||
IgnoreAutoTag bool `db:"ignore_auto_tag"`
|
||||
|
|
@ -46,6 +47,7 @@ type tagRow struct {
|
|||
func (r *tagRow) fromTag(o models.Tag) {
|
||||
r.ID = o.ID
|
||||
r.Name = null.StringFrom(o.Name)
|
||||
r.SortName = zero.StringFrom((o.SortName))
|
||||
r.Favorite = o.Favorite
|
||||
r.Description = zero.StringFrom(o.Description)
|
||||
r.IgnoreAutoTag = o.IgnoreAutoTag
|
||||
|
|
@ -57,6 +59,7 @@ func (r *tagRow) resolve() *models.Tag {
|
|||
ret := &models.Tag{
|
||||
ID: r.ID,
|
||||
Name: r.Name.String,
|
||||
SortName: r.SortName.String,
|
||||
Favorite: r.Favorite,
|
||||
Description: r.Description.String,
|
||||
IgnoreAutoTag: r.IgnoreAutoTag,
|
||||
|
|
@ -87,6 +90,7 @@ type tagRowRecord struct {
|
|||
|
||||
func (r *tagRowRecord) fromPartial(o models.TagPartial) {
|
||||
r.setString("name", o.Name)
|
||||
r.setNullString("sort_name", o.SortName)
|
||||
r.setNullString("description", o.Description)
|
||||
r.setBool("favorite", o.Favorite)
|
||||
r.setBool("ignore_auto_tag", o.IgnoreAutoTag)
|
||||
|
|
@ -672,6 +676,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
|
||||
sortQuery := ""
|
||||
switch sort {
|
||||
case "name":
|
||||
sortQuery += fmt.Sprintf(" ORDER BY COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI %s", getSortDirection(direction))
|
||||
case "scenes_count":
|
||||
sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction)
|
||||
case "scene_markers_count":
|
||||
|
|
@ -690,8 +696,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
sortQuery += getSort(sort, direction, "tags")
|
||||
}
|
||||
|
||||
// Whatever the sorting, always use name/id as a final sort
|
||||
sortQuery += ", COALESCE(tags.name, tags.id) COLLATE NATURAL_CI ASC"
|
||||
// Whatever the sorting, always use sort_name/name/id as a final sort
|
||||
sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC"
|
||||
return sortQuery, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
|
|||
tagFilter := qb.tagFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(tagFilter.Name, tagTable+".name"),
|
||||
stringCriterionHandler(tagFilter.SortName, tagTable+".sort_name"),
|
||||
qb.aliasCriterionHandler(tagFilter.Aliases),
|
||||
|
||||
boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type FinderAliasImageGetter interface {
|
|||
func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) {
|
||||
newTagJSON := jsonschema.Tag{
|
||||
Name: tag.Name,
|
||||
SortName: tag.SortName,
|
||||
Description: tag.Description,
|
||||
Favorite: tag.Favorite,
|
||||
IgnoreAutoTag: tag.IgnoreAutoTag,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const (
|
|||
|
||||
const (
|
||||
tagName = "testTag"
|
||||
sortName = "sortName"
|
||||
description = "description"
|
||||
)
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ func createTag(id int) models.Tag {
|
|||
return models.Tag{
|
||||
ID: id,
|
||||
Name: tagName,
|
||||
SortName: sortName,
|
||||
Favorite: true,
|
||||
Description: description,
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
|
|
@ -48,6 +50,7 @@ func createTag(id int) models.Tag {
|
|||
func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag {
|
||||
return &jsonschema.Tag{
|
||||
Name: tagName,
|
||||
SortName: sortName,
|
||||
Favorite: true,
|
||||
Description: description,
|
||||
Aliases: aliases,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type Importer struct {
|
|||
func (i *Importer) PreImport(ctx context.Context) error {
|
||||
i.tag = models.Tag{
|
||||
Name: i.Input.Name,
|
||||
SortName: i.Input.SortName,
|
||||
Description: i.Input.Description,
|
||||
Favorite: i.Input.Favorite,
|
||||
IgnoreAutoTag: i.Input.IgnoreAutoTag,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) {
|
|||
i := Importer{
|
||||
Input: jsonschema.Tag{
|
||||
Name: tagName,
|
||||
SortName: sortName,
|
||||
Description: description,
|
||||
Image: invalidImage,
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
fragment SlimTagData on Tag {
|
||||
id
|
||||
name
|
||||
sort_name
|
||||
aliases
|
||||
image_path
|
||||
parent_count
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
fragment TagData on Tag {
|
||||
id
|
||||
name
|
||||
sort_name
|
||||
description
|
||||
aliases
|
||||
ignore_auto_tag
|
||||
|
|
@ -33,6 +34,7 @@ fragment TagData on Tag {
|
|||
fragment SelectTagData on Tag {
|
||||
id
|
||||
name
|
||||
sort_name
|
||||
favorite
|
||||
description
|
||||
aliases
|
||||
|
|
@ -41,5 +43,6 @@ fragment SelectTagData on Tag {
|
|||
parents {
|
||||
id
|
||||
name
|
||||
sort_name
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,30 @@ type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & {
|
|||
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 {
|
||||
link: string;
|
||||
className?: string;
|
||||
|
|
@ -263,7 +287,11 @@ export const TagLink: React.FC<ITagLinkProps> = ({
|
|||
}, [hierarchyTooltipID]);
|
||||
|
||||
return (
|
||||
<CommonLinkComponent link={link} className={className}>
|
||||
<SortNameLinkComponent
|
||||
sortName={tag.sort_name || title}
|
||||
link={link}
|
||||
className={className}
|
||||
>
|
||||
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
|
||||
{title}
|
||||
{showHierarchyIcon && (
|
||||
|
|
@ -275,6 +303,6 @@ export const TagLink: React.FC<ITagLinkProps> = ({
|
|||
</OverlayTrigger>
|
||||
)}
|
||||
</TagPopover>
|
||||
</CommonLinkComponent>
|
||||
</SortNameLinkComponent>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
sort_name: yup.string().ensure(),
|
||||
aliases: yupUniqueAliases(intl, "name"),
|
||||
description: yup.string().ensure(),
|
||||
parent_ids: yup.array(yup.string().required()).defined(),
|
||||
|
|
@ -56,6 +57,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
const initialValues = {
|
||||
name: tag?.name ?? "",
|
||||
sort_name: tag?.sort_name ?? "",
|
||||
aliases: tag?.aliases ?? [],
|
||||
description: tag?.description ?? "",
|
||||
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">
|
||||
{renderInputField("name")}
|
||||
{renderInputField("sort_name", "text")}
|
||||
{renderStringListField("aliases")}
|
||||
{renderInputField("description", "textarea")}
|
||||
{renderParentTagsField()}
|
||||
|
|
|
|||
|
|
@ -1159,6 +1159,7 @@
|
|||
"megabits_per_second": "{value} mbps",
|
||||
"metadata": "Metadata",
|
||||
"name": "Name",
|
||||
"sort_name": "Sort Name",
|
||||
"new": "New",
|
||||
"none": "None",
|
||||
"o_count": "O Count",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
|
|||
const criterionOptions = [
|
||||
FavoriteTagCriterionOption,
|
||||
createMandatoryStringCriterionOption("name"),
|
||||
createStringCriterionOption("sort_name"),
|
||||
TagIsMissingCriterionOption,
|
||||
createStringCriterionOption("aliases"),
|
||||
createStringCriterionOption("description"),
|
||||
|
|
|
|||
|
|
@ -219,4 +219,5 @@ export type CriterionType =
|
|||
| "code"
|
||||
| "photographer"
|
||||
| "disambiguation"
|
||||
| "has_chapters";
|
||||
| "has_chapters"
|
||||
| "sort_name";
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const makePerformerImagesUrl = (
|
|||
export interface INamedObject {
|
||||
id: string;
|
||||
name?: string;
|
||||
sort_name?: string | null;
|
||||
}
|
||||
|
||||
const makePerformerGalleriesUrl = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue