diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 7600b563b..37e173a18 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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 diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 3c62c899c..504f23e3d 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -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 diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 2554f1bb5..1e8b6066a 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -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") diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index 18da3787b..ed2bc1c9c 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -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"` diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index e8a797e87..0d845750f 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -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 diff --git a/pkg/models/tag.go b/pkg/models/tag.go index ddab8baf5..1971a8bb6 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -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 diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index f30779fd3..20926ed25 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -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 { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 5ed803c17..4b7c23552 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -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 diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 90e46bebc..ec9b7ae2e 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -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{ diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 5a214f818..7f0ff72ca 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -122,7 +122,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "tags.name ASC", + orderBy: "COALESCE(tags.sort_name, tags.name) ASC", }, } ) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 4bc28fad8..840720c50 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -177,7 +177,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "tags.name ASC", + orderBy: "COALESCE(tags.sort_name, tags.name) ASC", }, } ) diff --git a/pkg/sqlite/migrations/72_tag_sort_name.up.sql b/pkg/sqlite/migrations/72_tag_sort_name.up.sql new file mode 100644 index 000000000..2e3e08e6d --- /dev/null +++ b/pkg/sqlite/migrations/72_tag_sort_name.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `tags` ADD COLUMN `sort_name` varchar(255); + diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index e291078b2..bcb984ffd 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -189,7 +189,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "tags.name ASC", + orderBy: "COALESCE(tags.sort_name, tags.name) ASC", }, stashIDs: stashIDRepository{ repository{ diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index edd363483..e45c3b5c7 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -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{ diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 6b81109b1..2467de6f5 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -133,7 +133,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "tags.name ASC", + orderBy: "COALESCE(tags.sort_name, tags.name) ASC", }, } ) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 919ef4847..00450085d 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -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 } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index ba9e9bb08..27afd5858 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -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), diff --git a/pkg/tag/export.go b/pkg/tag/export.go index d1d284a5f..58727f8f4 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -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, diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index afe96996b..6c008c170 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -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, diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 56421fdf6..21203afb0 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -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, diff --git a/pkg/tag/import_test.go b/pkg/tag/import_test.go index 6b8563992..b706c4937 100644 --- a/pkg/tag/import_test.go +++ b/pkg/tag/import_test.go @@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Tag{ Name: tagName, + SortName: sortName, Description: description, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, diff --git a/ui/v2.5/graphql/data/tag-slim.graphql b/ui/v2.5/graphql/data/tag-slim.graphql index e35660de6..3c498539b 100644 --- a/ui/v2.5/graphql/data/tag-slim.graphql +++ b/ui/v2.5/graphql/data/tag-slim.graphql @@ -1,6 +1,7 @@ fragment SlimTagData on Tag { id name + sort_name aliases image_path parent_count diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index b0501de69..5eae173ea 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -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 } } diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 253d22af0..63352aaa6 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -19,6 +19,30 @@ type SceneMarkerFragment = Pick & { primary_tag: Pick; }; +interface ISortNameLinkProps { + link: string; + className?: string; + sortName?: string; +} + +const SortNameLinkComponent: React.FC = ({ + link, + sortName, + className, + children, +}) => { + return ( + + {children} + + ); +}; + interface ICommonLinkProps { link: string; className?: string; @@ -263,7 +287,11 @@ export const TagLink: React.FC = ({ }, [hierarchyTooltipID]); return ( - + {title} {showHierarchyIcon && ( @@ -275,6 +303,6 @@ export const TagLink: React.FC = ({ )} - + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 6106d8026..da79b6c4e 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -46,6 +46,7 @@ export const TagEditPanel: React.FC = ({ 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 = ({ 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 = ({
{renderInputField("name")} + {renderInputField("sort_name", "text")} {renderStringListField("aliases")} {renderInputField("description", "textarea")} {renderParentTagsField()} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 99e14b637..7611340a3 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index b400d9d60..db3a84666 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -53,6 +53,7 @@ const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; const criterionOptions = [ FavoriteTagCriterionOption, createMandatoryStringCriterionOption("name"), + createStringCriterionOption("sort_name"), TagIsMissingCriterionOption, createStringCriterionOption("aliases"), createStringCriterionOption("description"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 48e37c046..93d24765a 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -219,4 +219,5 @@ export type CriterionType = | "code" | "photographer" | "disambiguation" - | "has_chapters"; + | "has_chapters" + | "sort_name"; diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index f6712fb58..b67c3ad46 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -87,6 +87,7 @@ const makePerformerImagesUrl = ( export interface INamedObject { id: string; name?: string; + sort_name?: string | null; } const makePerformerGalleriesUrl = (