Files
grout/internal/stringutil/functions.go
pawndev ce8a3d66fd fix(es/gamelist): Sanitize game name
If using DisplayName, the name of the game will have the downloaded mark in the gamelist.xml file. So using the game.Name attribute, and sanitize it the same way as other places in the code
2026-02-04 08:54:44 +01:00

293 lines
6.4 KiB
Go

package stringutil
import (
"fmt"
"grout/romm"
"path/filepath"
"regexp"
"slices"
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
var TagRegex = regexp.MustCompile(`\((.*?)\)`)
var OrderedFolderRegex = regexp.MustCompile(`\d+\)\s`)
var BracketRegex = regexp.MustCompile(`\[.*?]`)
func StripExtension(filename string) string {
return strings.TrimSuffix(filename, filepath.Ext(filename))
}
func FormatBytes(bytes int64) string {
const unit int64 = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := unit, 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func ParseTag(input string) string {
cleaned := filepath.Clean(input)
tags := TagRegex.FindAllStringSubmatch(cleaned, -1)
var foundTags []string
foundTag := ""
if len(tags) > 0 {
for _, tagPair := range tags {
foundTags = append(foundTags, tagPair[0])
}
foundTag = strings.Join(foundTags, " ")
}
foundTag = strings.ReplaceAll(foundTag, "(", "")
foundTag = strings.ReplaceAll(foundTag, ")", "")
return foundTag
}
func nameCleaner(name string, stripTag bool) (string, string) {
cleaned := filepath.Clean(name)
tags := TagRegex.FindAllStringSubmatch(cleaned, -1)
var foundTags []string
foundTag := ""
if len(tags) > 0 {
for _, tagPair := range tags {
foundTags = append(foundTags, tagPair[0])
}
foundTag = strings.Join(foundTags, " ")
}
if stripTag {
for _, tag := range foundTags {
cleaned = strings.ReplaceAll(cleaned, tag, "")
}
}
orderedFolderRegex := OrderedFolderRegex.FindStringSubmatch(cleaned)
if len(orderedFolderRegex) > 0 {
cleaned = strings.ReplaceAll(cleaned, orderedFolderRegex[0], "")
}
cleaned = strings.ReplaceAll(cleaned, ":", " -")
cleaned = strings.TrimSpace(cleaned)
foundTag = strings.ReplaceAll(foundTag, "(", "")
foundTag = strings.ReplaceAll(foundTag, ")", "")
return cleaned, foundTag
}
func PrepareRomNames(games []romm.Rom) []romm.Rom {
for i := range games {
games[i].DisplayName = PrepareRomName(games[i].Name, games[i].Regions)
}
slices.SortFunc(games, func(a, b romm.Rom) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return games
}
func PrepareRomName(name string, regions []string) string {
r := strings.Join(regions, ", ")
cleanedName, _ := nameCleaner(name, true)
displayName := cleanedName
if len(regions) > 0 {
dn := fmt.Sprintf("%s (%s)", cleanedName, r)
displayName = dn
}
return displayName
}
// stripDiacritics removes accents and diacritical marks from a string.
// e.g., "Pokémon" -> "Pokemon"
func stripDiacritics(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return result
}
func NormalizeForComparison(name string) string {
name = StripExtension(name)
name, _ = nameCleaner(name, true)
name = BracketRegex.ReplaceAllString(name, "")
name = strings.ToLower(strings.TrimSpace(name))
// Strip diacritics so "Pokémon" matches "Pokemon"
name = stripDiacritics(name)
name = strings.ReplaceAll(name, "-", " ")
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, ".", " ")
for strings.Contains(name, " ") {
name = strings.ReplaceAll(name, " ", " ")
}
return strings.TrimSpace(name)
}
func LevenshteinDistance(s1, s2 string) int {
if len(s1) == 0 {
return len(s2)
}
if len(s2) == 0 {
return len(s1)
}
r1 := []rune(s1)
r2 := []rune(s2)
rows := len(r1) + 1
cols := len(r2) + 1
matrix := make([][]int, rows)
for i := range matrix {
matrix[i] = make([]int, cols)
}
for i := 0; i < rows; i++ {
matrix[i][0] = i
}
for j := 0; j < cols; j++ {
matrix[0][j] = j
}
for i := 1; i < rows; i++ {
for j := 1; j < cols; j++ {
cost := 1
if r1[i-1] == r2[j-1] {
cost = 0
}
matrix[i][j] = min(
matrix[i-1][j]+1,
matrix[i][j-1]+1,
matrix[i-1][j-1]+cost,
)
}
}
return matrix[rows-1][cols-1]
}
func Similarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
maxLen := max(len(s1), len(s2))
if maxLen == 0 {
return 1.0
}
distance := LevenshteinDistance(s1, s2)
return 1.0 - float64(distance)/float64(maxLen)
}
// PrefixMatchSimilarity checks if the shorter string is a prefix of the longer
// string at a word boundary. Returns 0.95 if matched (high confidence but not
// exact), 0.0 if not. This handles cases like "Pokemon Red Nuzlocke" matching
// "Pokemon Red" where the user added a custom suffix.
func PrefixMatchSimilarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
var shorter, longer string
if len(s1) <= len(s2) {
shorter, longer = s1, s2
} else {
shorter, longer = s2, s1
}
if shorter == "" {
return 0.0
}
// Check if longer starts with shorter
if !strings.HasPrefix(longer, shorter) {
return 0.0
}
// Ensure it's at a word boundary (next char is space or end of string)
if len(longer) == len(shorter) {
return 1.0 // Exact match
}
nextChar := longer[len(shorter)]
if nextChar == ' ' {
return 0.95 // High confidence prefix match at word boundary
}
return 0.0
}
// CommonPrefixSimilarity checks if two strings share a common word prefix.
// This handles cases like "pokemon red nuzlocke" matching "pokemon red version"
// where both share "pokemon red" as a common prefix.
// Returns 0.85 if at least 2 words match from the start, indicating high confidence.
func CommonPrefixSimilarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
words1 := strings.Fields(s1)
words2 := strings.Fields(s2)
if len(words1) == 0 || len(words2) == 0 {
return 0.0
}
// Count matching words from the start
matchingWords := 0
minWords := min(len(words1), len(words2))
for i := 0; i < minWords; i++ {
if words1[i] == words2[i] {
matchingWords++
} else {
break
}
}
// Require at least 2 matching words to be considered a match
// This ensures "Pokemon Red Nuzlocke" matches "Pokemon Red Version"
// but "Pokemon Nuzlocke" won't match "Pokemon Stadium"
if matchingWords >= 2 {
return 0.85
}
return 0.0
}
// BestSimilarity returns the highest similarity score between two strings,
// using Levenshtein similarity, prefix matching, and common prefix matching.
func BestSimilarity(s1, s2 string) float64 {
levenshtein := Similarity(s1, s2)
prefix := PrefixMatchSimilarity(s1, s2)
commonPrefix := CommonPrefixSimilarity(s1, s2)
return max(levenshtein, prefix, commonPrefix)
}