Sort case insensitive, date by newest first (#3560)

* Case insensitive search
* Fix not adding extra sort when no sort specified.
* Using newer version of fvbommel/sortorder package
This commit is contained in:
Flashy78
2023-04-16 22:21:13 -07:00
committed by GitHub
parent e685f80e3d
commit 75f22042b7
19 changed files with 311 additions and 165 deletions

2
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/chromedp/chromedp v0.7.3 github.com/chromedp/chromedp v0.7.3
github.com/corona10/goimagehash v1.0.3 github.com/corona10/goimagehash v1.0.3
github.com/disintegration/imaging v1.6.0 github.com/disintegration/imaging v1.6.0
github.com/fvbommel/sortorder v1.0.2 github.com/fvbommel/sortorder v1.1.0
github.com/go-chi/chi v4.0.2+incompatible github.com/go-chi/chi v4.0.2+incompatible
github.com/golang-jwt/jwt/v4 v4.0.0 github.com/golang-jwt/jwt/v4 v4.0.0
github.com/golang-migrate/migrate/v4 v4.15.0-beta.1 github.com/golang-migrate/migrate/v4 v4.15.0-beta.1

4
go.sum
View File

@@ -233,8 +233,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=

View File

@@ -5,7 +5,7 @@ import (
"database/sql/driver" "database/sql/driver"
"fmt" "fmt"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder/casefolded"
sqlite3 "github.com/mattn/go-sqlite3" sqlite3 "github.com/mattn/go-sqlite3"
) )
@@ -37,9 +37,9 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) {
} }
} }
// COLLATE NATURAL_CS - Case sensitive natural sort // COLLATE NATURAL_CI - Case insensitive natural sort
err := conn.RegisterCollation("NATURAL_CS", func(s string, s2 string) int { err := conn.RegisterCollation("NATURAL_CI", func(s string, s2 string) int {
if sortorder.NaturalLess(s, s2) { if casefolded.NaturalLess(s, s2) {
return -1 return -1
} else { } else {
return 1 return 1

View File

@@ -1128,7 +1128,7 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
// special handling for path // special handling for path
addFileTable() addFileTable()
addFolderTable() addFolderTable()
query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, file_folder.path %[1]s, files.basename %[1]s", direction) query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(file_folder.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction)
case "file_mod_time": case "file_mod_time":
sort = "mod_time" sort = "mod_time"
addFileTable() addFileTable()
@@ -1136,10 +1136,13 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
case "title": case "title":
addFileTable() addFileTable()
addFolderTable() addFolderTable()
query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CS " + direction + ", file_folder.path " + direction query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CI " + direction + ", file_folder.path COLLATE NATURAL_CI " + direction
default: default:
query.sortAndPagination += getSort(sort, direction, "galleries") query.sortAndPagination += getSort(sort, direction, "galleries")
} }
// Whatever the sorting, always use title/id as a final sort
query.sortAndPagination += ", COALESCE(galleries.title, galleries.id) COLLATE NATURAL_CI ASC"
} }
func (qb *GalleryStore) filesRepository() *filesRepository { func (qb *GalleryStore) filesRepository() *filesRepository {

View File

@@ -1026,7 +1026,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
case "path": case "path":
addFilesJoin() addFilesJoin()
addFolderJoin() addFolderJoin()
sortClause = " ORDER BY folders.path " + direction + ", files.basename " + direction sortClause = " ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI " + direction
case "file_count": case "file_count":
sortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction) sortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction)
case "tag_count": case "tag_count":
@@ -1039,10 +1039,13 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
case "title": case "title":
addFilesJoin() addFilesJoin()
addFolderJoin() addFolderJoin()
sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CS " + direction + ", folders.path " + direction sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction
default: default:
sortClause = getSort(sort, direction, "images") sortClause = getSort(sort, direction, "images")
} }
// Whatever the sorting, always use title/id as a final sort
sortClause += ", COALESCE(images.title, images.id) COLLATE NATURAL_CI ASC"
} }
q.sortAndPagination = sortClause + getPagination(findFilter) q.sortAndPagination = sortClause + getPagination(findFilter)

View File

@@ -310,14 +310,17 @@ func (qb *movieQueryBuilder) getMovieSort(findFilter *models.FindFilterType) str
direction = findFilter.GetDirection() direction = findFilter.GetDirection()
} }
sortQuery := ""
switch sort { switch sort {
case "name": // #943 - override name sorting to use natural sort
return " ORDER BY " + getColumn("movies", sort) + " COLLATE NATURAL_CS " + direction
case "scenes_count": // generic getSort won't work for this case "scenes_count": // generic getSort won't work for this
return getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction)
default: default:
return getSort(sort, direction, "movies") sortQuery += getSort(sort, direction, "movies")
} }
// Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(movies.name, movies.id) COLLATE NATURAL_CI ASC"
return sortQuery
} }
func (qb *movieQueryBuilder) queryMovie(ctx context.Context, query string, args []interface{}) (*models.Movie, error) { func (qb *movieQueryBuilder) queryMovie(ctx context.Context, query string, args []interface{}) (*models.Movie, error) {

View File

@@ -893,20 +893,23 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) st
direction = findFilter.GetDirection() direction = findFilter.GetDirection()
} }
if sort == "tag_count" { sortQuery := ""
return getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) switch sort {
} case "tag_count":
if sort == "scenes_count" { sortQuery += getCountSort(performerTable, performersTagsTable, performerIDColumn, direction)
return getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) case "scenes_count":
} sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction)
if sort == "images_count" { case "images_count":
return getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction)
} case "galleries_count":
if sort == "galleries_count" { sortQuery += getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction)
return getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction) default:
sortQuery += getSort(sort, direction, "performers")
} }
return getSort(sort, direction, "performers") // Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC"
return sortQuery
} }
func (qb *PerformerStore) tagsRepository() *joinRepository { func (qb *PerformerStore) tagsRepository() *joinRepository {

View File

@@ -1435,7 +1435,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF
// special handling for path // special handling for path
addFileTable() addFileTable()
addFolderTable() addFolderTable()
query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, files.basename %[1]s", direction) query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction)
case "perceptual_similarity": case "perceptual_similarity":
// special handling for phash // special handling for phash
addFileTable() addFileTable()
@@ -1472,13 +1472,16 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF
case "title": case "title":
addFileTable() addFileTable()
addFolderTable() addFolderTable()
query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CS " + direction + ", folders.path " + direction query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction
case "play_count": case "play_count":
// handle here since getSort has special handling for _count suffix // handle here since getSort has special handling for _count suffix
query.sortAndPagination += " ORDER BY scenes.play_count " + direction query.sortAndPagination += " ORDER BY scenes.play_count " + direction
default: default:
query.sortAndPagination += getSort(sort, direction, "scenes") query.sortAndPagination += getSort(sort, direction, "scenes")
} }
// Whatever the sorting, always use title/id as a final sort
query.sortAndPagination += ", COALESCE(scenes.title, scenes.id) COLLATE NATURAL_CI ASC"
} }
func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) { func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) {

View File

@@ -82,10 +82,10 @@ func getSort(sort string, direction string, tableName string) string {
colName = sort colName = sort
} }
if strings.Compare(sort, "name") == 0 { if strings.Compare(sort, "name") == 0 {
return " ORDER BY " + colName + " COLLATE NOCASE " + direction return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction
} }
if strings.Compare(sort, "title") == 0 { if strings.Compare(sort, "title") == 0 {
return " ORDER BY " + colName + " COLLATE NATURAL_CS " + direction return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction
} }
return " ORDER BY " + colName + " " + direction return " ORDER BY " + colName + " " + direction

View File

@@ -414,16 +414,21 @@ func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) s
direction = findFilter.GetDirection() direction = findFilter.GetDirection()
} }
sortQuery := ""
switch sort { switch sort {
case "scenes_count": case "scenes_count":
return getCountSort(studioTable, sceneTable, studioIDColumn, direction) sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction)
case "images_count": case "images_count":
return getCountSort(studioTable, imageTable, studioIDColumn, direction) sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction)
case "galleries_count": case "galleries_count":
return getCountSort(studioTable, galleryTable, studioIDColumn, direction) sortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction)
default: default:
return getSort(sort, direction, "studios") sortQuery += getSort(sort, direction, "studios")
} }
// Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC"
return sortQuery
} }
func (qb *studioQueryBuilder) queryStudio(ctx context.Context, query string, args []interface{}) (*models.Studio, error) { func (qb *studioQueryBuilder) queryStudio(ctx context.Context, query string, args []interface{}) (*models.Studio, error) {

View File

@@ -609,22 +609,25 @@ func (qb *tagQueryBuilder) getTagSort(query *queryBuilder, findFilter *models.Fi
direction = findFilter.GetDirection() direction = findFilter.GetDirection()
} }
if findFilter.Sort != nil { sortQuery := ""
switch *findFilter.Sort { switch sort {
case "scenes_count": case "scenes_count":
return getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction)
case "scene_markers_count": case "scene_markers_count":
return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction))
case "images_count": case "images_count":
return getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction)
case "galleries_count": case "galleries_count":
return getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)
case "performers_count": case "performers_count":
return getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction)
} default:
sortQuery += getSort(sort, direction, "tags")
} }
return 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"
return sortQuery
} }
func (qb *tagQueryBuilder) queryTag(ctx context.Context, query string, args []interface{}) (*models.Tag, error) { func (qb *tagQueryBuilder) queryTag(ctx context.Context, query string, args []interface{}) (*models.Tag, error) {

View File

@@ -44,7 +44,7 @@ export class ListFilterModel {
public searchTerm: string = ""; public searchTerm: string = "";
public currentPage = DEFAULT_PARAMS.currentPage; public currentPage = DEFAULT_PARAMS.currentPage;
public itemsPerPage = DEFAULT_PARAMS.itemsPerPage; public itemsPerPage = DEFAULT_PARAMS.itemsPerPage;
public sortDirection: SortDirectionEnum = SortDirectionEnum.Asc; public sortDirection: SortDirectionEnum = DEFAULT_PARAMS.sortDirection;
public sortBy?: string; public sortBy?: string;
public displayMode: DisplayMode = DEFAULT_PARAMS.displayMode; public displayMode: DisplayMode = DEFAULT_PARAMS.displayMode;
public zoomIndex: number = 1; public zoomIndex: number = 1;
@@ -62,6 +62,9 @@ export class ListFilterModel {
this.mode = mode; this.mode = mode;
this.config = config; this.config = config;
this.sortBy = defaultSort; this.sortBy = defaultSort;
if (this.sortBy === "date") {
this.sortDirection = SortDirectionEnum.Desc;
}
if (defaultDisplayMode !== undefined) { if (defaultDisplayMode !== undefined) {
this.displayMode = defaultDisplayMode; this.displayMode = defaultDisplayMode;
} }
@@ -95,12 +98,19 @@ export class ListFilterModel {
this.randomSeed = Number.parseInt(match[1], 10); this.randomSeed = Number.parseInt(match[1], 10);
} }
} }
// #3193 - sortdir undefined means asc if (params.sortdir !== undefined) {
this.sortDirection = this.sortDirection =
params.sortdir === "desc" params.sortdir === "desc"
? SortDirectionEnum.Desc ? SortDirectionEnum.Desc
: SortDirectionEnum.Asc; : SortDirectionEnum.Asc;
} else {
// #3193 - sortdir undefined means asc
// #3559 - unless sortby is date, then desc
this.sortDirection =
params.sortby === "date"
? SortDirectionEnum.Desc
: SortDirectionEnum.Asc;
}
if (params.disp !== undefined) { if (params.disp !== undefined) {
this.displayMode = params.disp; this.displayMode = params.disp;
} }
@@ -294,7 +304,13 @@ export class ListFilterModel {
: undefined, : undefined,
sortby: this.getSortBy(), sortby: this.getSortBy(),
sortdir: sortdir:
this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, this.sortBy === "date"
? this.sortDirection === SortDirectionEnum.Asc
? "asc"
: undefined
: this.sortDirection === SortDirectionEnum.Desc
? "desc"
: undefined,
disp: disp:
this.displayMode !== DEFAULT_PARAMS.displayMode this.displayMode !== DEFAULT_PARAMS.displayMode
? String(this.displayMode) ? String(this.displayMode)
@@ -321,7 +337,13 @@ export class ListFilterModel {
perPage: this.itemsPerPage, perPage: this.itemsPerPage,
sortby: this.getSortBy(), sortby: this.getSortBy(),
sortdir: sortdir:
this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, this.sortBy === "date"
? this.sortDirection === SortDirectionEnum.Asc
? "asc"
: undefined
: this.sortDirection === SortDirectionEnum.Desc
? "desc"
: undefined,
disp: this.displayMode, disp: this.displayMode,
q: this.searchTerm || undefined, q: this.searchTerm || undefined,
z: this.zoomIndex, z: this.zoomIndex,

View File

@@ -1,19 +0,0 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof

View File

@@ -1,5 +0,0 @@
# sortorder [![PkgGoDev](https://pkg.go.dev/badge/github.com/fvbommel/sortorder)](https://pkg.go.dev/github.com/fvbommel/sortorder)
import "github.com/fvbommel/sortorder"
Sort orders and comparison functions.

View File

@@ -0,0 +1,12 @@
# casefolded [![PkgGoDev](https://pkg.go.dev/badge/github.com/fvbommel/sortorder/casefolded)](https://pkg.go.dev/github.com/fvbommel/sortorder/casefolded)
import "github.com/fvbommel/sortorder/casefolded"
Case-folded sort orders and comparison functions.
These sort characters as the lowest unicode value that is equivalent to that character, ignoring case.
Not all Unicode special cases are supported.
This is a separate sub-package because this needs to pull in the Unicode tables in the standard library,
which can add significantly to the size of binaries.

View File

@@ -0,0 +1,194 @@
package casefolded
import (
"unicode"
"unicode/utf8"
)
// Natural implements sort.Interface to sort strings in natural order. This
// means that e.g. "abc2" < "abc12".
//
// This is the simple case-folded version,
// which means that letters are considered equal if strings.SimpleFold says they are.
// For example, "abc2" < "ABC12" < "abc100" and 'k' == '\u212a' (the Kelvin symbol).
//
// Non-digit sequences and numbers are compared separately.
// The former are compared rune-by-rune using the lowest equivalent runes,
// while digits are compared numerically
// (except that the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitations:
// - only ASCII digits (0-9) are considered.
// - comparisons are done on a rune-by-rune basis,
// so some special case equivalences like 'ß' == 'SS" are not supported.
// - Special cases like Turkish 'i' == 'İ' (and not regular dotless 'I')
// are not supported either.
type Natural []string
func (n Natural) Len() int { return len(n) }
func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) }
func isDigit(b rune) bool { return '0' <= b && b <= '9' }
// caseFold returns the lowest-numbered rune equivalent to the parameter.
func caseFold(r rune) rune {
// Iterate until SimpleFold returns a lower value.
// This will be the lowest-numbered equivalent rune.
var prev rune = -1
for r > prev {
prev, r = r, unicode.SimpleFold(r)
}
return r
}
// NaturalLess compares two strings using natural ordering. This means that e.g.
// "abc2" < "abc12".
//
// This is the simple case-folded version,
// which means that letters are considered equal if strings.SimpleFold says they are.
// For example, "abc2" < "ABC12" < "abc100" and 'k' == '\u212a' (the Kelvin symbol).
//
// Non-digit sequences and numbers are compared separately.
// The former are compared rune-by-rune using the lowest equivalent runes,
// while digits are compared numerically
// (except that the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitations:
// - only ASCII digits (0-9) are considered.
// - comparisons are done on a rune-by-rune basis,
// so some special case equivalences like 'ß' == 'SS" are not supported.
// - Special cases like Turkish 'i' == 'İ' (and not regular dotless 'I')
// are not supported either.
func NaturalLess(str1, str2 string) bool {
// ASCII fast path.
idx1, idx2 := 0, 0
for idx1 < len(str1) && idx2 < len(str2) {
c1, c2 := rune(str1[idx1]), rune(str2[idx2])
// Bail out to full Unicode support?
if c1|c2 >= utf8.RuneSelf {
goto hasUnicode
}
dig1, dig2 := isDigit(c1), isDigit(c2)
switch {
case dig1 != dig2: // Digits before other characters.
return dig1 // True if LHS is a digit, false if the RHS is one.
case !dig1: // && !dig2, because dig1 == dig2
// For ASCII it suffices to normalize letters to upper-case,
// because upper-cased ASCII compares lexicographically.
// Note: this does not account for regional special cases
// like Turkish dotted capital 'İ'.
// Canonicalize to upper-case.
c1 = unicode.ToUpper(c1)
c2 = unicode.ToUpper(c2)
// Identical upper-cased ASCII runes are equal.
if c1 == c2 {
idx1++
idx2++
continue
}
return c1 < c2
default: // Digits
// Eat zeros.
for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ {
}
for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ {
}
// Eat all digits.
nonZero1, nonZero2 := idx1, idx2
for ; idx1 < len(str1) && isDigit(rune(str1[idx1])); idx1++ {
}
for ; idx2 < len(str2) && isDigit(rune(str2[idx2])); idx2++ {
}
// If lengths of numbers with non-zero prefix differ, the shorter
// one is less.
if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 {
return len1 < len2
}
// If they're equally long, string comparison is correct.
if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 {
return nr1 < nr2
}
// Otherwise, the one with less zeros is less.
// Because everything up to the number is equal, comparing the index
// after the zeros is sufficient.
if nonZero1 != nonZero2 {
return nonZero1 < nonZero2
}
}
// They're identical so far, so continue comparing.
}
// So far they are identical. At least one is ended. If the other continues,
// it sorts last.
return len(str1) < len(str2)
hasUnicode:
for idx1 < len(str1) && idx2 < len(str2) {
c1, delta1 := utf8.DecodeRuneInString(str1[idx1:])
c2, delta2 := utf8.DecodeRuneInString(str2[idx2:])
dig1, dig2 := isDigit(c1), isDigit(c2)
switch {
case dig1 != dig2: // Digits before other characters.
return dig1 // True if LHS is a digit, false if the RHS is one.
case !dig1: // && !dig2, because dig1 == dig2
idx1 += delta1
idx2 += delta2
// Fast path: identical runes are equal.
if c1 == c2 {
continue
}
// ASCII fast path: ASCII characters compare by their upper-case equivalent (if any)
// because 'A' < 'a', so upper-case them.
if c1 <= unicode.MaxASCII && c2 <= unicode.MaxASCII {
c1 = unicode.ToUpper(c1)
c2 = unicode.ToUpper(c2)
if c1 != c2 {
return c1 < c2
}
continue
}
// Compare lowest equivalent characters.
c1 = caseFold(c1)
c2 = caseFold(c2)
if c1 == c2 {
continue
}
return c1 < c2
default: // Digits
// Eat zeros.
for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ {
}
for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ {
}
// Eat all digits.
nonZero1, nonZero2 := idx1, idx2
for ; idx1 < len(str1) && isDigit(rune(str1[idx1])); idx1++ {
}
for ; idx2 < len(str2) && isDigit(rune(str2[idx2])); idx2++ {
}
// If lengths of numbers with non-zero prefix differ, the shorter
// one is less.
if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 {
return len1 < len2
}
// If they're equally long, string comparison is correct.
if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 {
return nr1 < nr2
}
// Otherwise, the one with less zeros is less.
// Because everything up to the number is equal, comparing the index
// after the zeros is sufficient.
if nonZero1 != nonZero2 {
return nonZero1 < nonZero2
}
}
// They're identical so far, so continue comparing.
}
// So far they are identical. At least one is ended. If the other continues,
// it sorts last.
return len(str1[idx1:]) < len(str2[idx2:])
}

View File

@@ -1,5 +0,0 @@
// Package sortorder implements sort orders and comparison functions.
//
// Currently, it only implements so-called "natural order", where integers
// embedded in strings are compared by value.
package sortorder // import "github.com/fvbommel/sortorder"

View File

@@ -1,76 +0,0 @@
package sortorder
// Natural implements sort.Interface to sort strings in natural order. This
// means that e.g. "abc2" < "abc12".
//
// Non-digit sequences and numbers are compared separately. The former are
// compared bytewise, while the latter are compared numerically (except that
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitation: only ASCII digits (0-9) are considered.
type Natural []string
func (n Natural) Len() int { return len(n) }
func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) }
func isdigit(b byte) bool { return '0' <= b && b <= '9' }
// NaturalLess compares two strings using natural ordering. This means that e.g.
// "abc2" < "abc12".
//
// Non-digit sequences and numbers are compared separately. The former are
// compared bytewise, while the latter are compared numerically (except that
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
//
// Limitation: only ASCII digits (0-9) are considered.
func NaturalLess(str1, str2 string) bool {
idx1, idx2 := 0, 0
for idx1 < len(str1) && idx2 < len(str2) {
c1, c2 := str1[idx1], str2[idx2]
dig1, dig2 := isdigit(c1), isdigit(c2)
switch {
case dig1 != dig2: // Digits before other characters.
return dig1 // True if LHS is a digit, false if the RHS is one.
case !dig1: // && !dig2, because dig1 == dig2
// UTF-8 compares bytewise-lexicographically, no need to decode
// codepoints.
if c1 != c2 {
return c1 < c2
}
idx1++
idx2++
default: // Digits
// Eat zeros.
for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ {
}
for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ {
}
// Eat all digits.
nonZero1, nonZero2 := idx1, idx2
for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ {
}
for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ {
}
// If lengths of numbers with non-zero prefix differ, the shorter
// one is less.
if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 {
return len1 < len2
}
// If they're equal, string comparison is correct.
if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 {
return nr1 < nr2
}
// Otherwise, the one with less zeros is less.
// Because everything up to the number is equal, comparing the index
// after the zeros is sufficient.
if nonZero1 != nonZero2 {
return nonZero1 < nonZero2
}
}
// They're identical so far, so continue comparing.
}
// So far they are identical. At least one is ended. If the other continues,
// it sorts last.
return len(str1) < len(str2)
}

4
vendor/modules.txt vendored
View File

@@ -143,9 +143,9 @@ github.com/doug-martin/goqu/v9/sqlgen
# github.com/fsnotify/fsnotify v1.5.1 # github.com/fsnotify/fsnotify v1.5.1
## explicit; go 1.13 ## explicit; go 1.13
github.com/fsnotify/fsnotify github.com/fsnotify/fsnotify
# github.com/fvbommel/sortorder v1.0.2 # github.com/fvbommel/sortorder v1.1.0
## explicit; go 1.13 ## explicit; go 1.13
github.com/fvbommel/sortorder github.com/fvbommel/sortorder/casefolded
# github.com/go-chi/chi v4.0.2+incompatible # github.com/go-chi/chi v4.0.2+incompatible
## explicit ## explicit
github.com/go-chi/chi github.com/go-chi/chi