Files
sm-user-plugin/classes/handle_generator.go
Jakub Zych 15b19c7021 feat(19-04): add users.handle, HandleGenerator and user.Created
Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:44:59 +02:00

161 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package classes
import (
"context"
"errors"
"strconv"
"strings"
"unicode"
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
const (
handleMinLen = 2
handleMaxLen = 255
)
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
// length 2–255, so every generated handle is matchable as an @-token.
type HandleGenerator struct{}
// Slug transliterates name to ASCII, lowercases, drops every rune outside
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
// is not a valid base (Generate falls back to user{id}).
func (HandleGenerator) Slug(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
b.WriteString(foldHandleRune(r))
}
return strings.Trim(b.String(), "._-")
}
// Generate returns a unique handle for name. The base is Slug(name); if
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
// when no id is known). The smallest free integer suffix is then appended
// until isTaken returns false: first "James" → "james", second → "james1".
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
base := g.Slug(name)
if len(base) < handleMinLen {
if fallbackID != nil {
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
} else {
base = "user"
}
}
base = clipHandle(base)
candidate := base
i := 0
for isTaken != nil && isTaken(candidate) {
i++
candidate = clipHandle(base + strconv.Itoa(i))
}
return candidate
}
// AssignHandle writes a unique handle onto user when it has none, using
// the user's display name and id. It is idempotent when handle is already
// set. The write uses db (the create transaction when there is one).
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
if user == nil {
return errors.New("golem15.user: a handle needs a user")
}
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
return nil
}
if db == nil {
return errors.New("golem15.user: a handle needs a database")
}
name := ""
if user.Name != nil {
name = *user.Name
}
var fallback *uint
if user.ID != 0 {
id := user.ID
fallback = &id
}
gen := HandleGenerator{}
for attempt := 0; attempt < 8; attempt++ {
handle := gen.Generate(name, func(candidate string) bool {
var n int64
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
if user.ID != 0 {
q = q.Where("id <> ?", user.ID)
}
if err := q.Count(&n).Error; err != nil {
return true
}
return n > 0
}, fallback)
user.Handle = &handle
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
if res.Error == nil {
return nil
}
if !isUniqueHandleConflict(res.Error) {
return res.Error
}
}
return errors.New("golem15.user: could not allocate a unique handle")
}
func clipHandle(s string) string {
if len(s) <= handleMaxLen {
return s
}
return s[:handleMaxLen]
}
func isUniqueHandleConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
}
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
func foldHandleRune(r rune) string {
r = unicode.ToLower(r)
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
return string(r)
}
if mapped, ok := latinHandleFold[r]; ok {
return mapped
}
return ""
}
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
// would emit for the names we generate handles from (Polish, French, German).
var latinHandleFold = map[rune]string{
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
'æ': "ae",
'ç': "c", 'ć': "c", 'č': "c",
'ď': "d", 'đ': "d", 'ð': "d",
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
'ł': "l",
'ñ': "n", 'ń': "n", 'ň': "n",
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
'œ': "oe",
'ř': "r",
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
'ť': "t", 'þ': "th",
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
'ý': "y", 'ÿ': "y",
'ź': "z", 'ż': "z", 'ž': "z",
}