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", }