Files
libredesk/internal/stringutil/stringutil.go
T
Abhinav Raut 76db5aa851 fix help center bugs and share the embedding and article-content code
Review pass over the help center commit. Bug fixes:

- Image-only or video-only articles were rejected on save because the
  editor blanked the content when it had no text.
- An article could be moved into a collection in another help center.
  Slugs are only unique per help center, so two articles could end up
  on the same public URL.
- The article editor fired the "agent is typing" indicator into
  whatever conversation was last open, which the contact could see.
- Unsupported locales like /hc/x/zz-ZZ returned an empty 200 page with
  a canonical URL, so crawlers saw unlimited URLs. They 404 now.
- Article feedback was accepted for paused help centers.
- French pages announced lang="en".
- Editing a help center popped validation errors mid-keystroke.

Shared code that was duplicated:

- Help articles had their own copy of the snippet embedding lifecycle.
  The copy was missing the wait group, the concurrency cap and the
  generation gating, so article embeds were killed at shutdown, all hit
  the provider at once, and a slow embed could overwrite a newer edit or
  bring back a deleted article. Both content types now go through one
  embedSource lifecycle. Fingerprints are unchanged, so nothing
  re-embeds on deploy.
- Callout and collapsible CSS was written twice, once for the editor and
  once for the public page. They now share article-content.css, so the
  editor stays WYSIWYG.
- The excerpt was computed three ways. The page uses the stored one.

Less work at runtime:

- The admin tree no longer sends every article's HTML. The edit sheet
  loads the one article it needs. Publishing an article used to
  re-download the whole help center.
- Embedding reconcile no longer reads every article body every minute.
- The tree view fetched the help center twice on load.

Cleanups: one update-article route instead of two, media privacy comes
from the model type instead of a bool threaded through the handler, a
LinkListField component replaces three copies of the same repeater,
TreeNode renders the article row once, and 12 duplicate or dead i18n
keys are gone.
2026-07-25 15:50:47 +05:30

292 lines
7.7 KiB
Go

// Package stringutil provides string utility functions.
package stringutil
import (
"crypto/rand"
"fmt"
"net/mail"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/jaytaylor/html2text"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
const (
PasswordDummy = "•"
)
var (
regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`)
regexpSpaces = regexp.MustCompile(`[\s]+`)
uuidV4Regex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}`)
regexpRefNumber = regexp.MustCompile(`#(\d+)`)
regexpSlugChars = regexp.MustCompile(`[^a-z0-9\-_]+`)
regexpHyphens = regexp.MustCompile(`-+`)
regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`)
// markdownRenderer escapes raw HTML in the input; single newlines render as <br>.
markdownRenderer = goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(html.WithHardWraps()),
)
)
// SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character.
func SanitizeUTF8(s string) string {
if s == "" {
return s
}
s = strings.ReplaceAll(s, "\x00", "")
return strings.ToValidUTF8(s, "")
}
// GenerateSlug generates a URL-friendly slug from a title.
func GenerateSlug(title string) string {
slug := strings.ToLower(strings.TrimSpace(title))
slug = regexpSpaces.ReplaceAllString(slug, "-")
slug = regexpSlugChars.ReplaceAllString(slug, "")
slug = regexpHyphens.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
if slug == "" {
randomSlug, err := RandomAlphanumeric(12)
if err != nil {
slug = "untitled"
} else {
slug = strings.ToLower(randomSlug)
}
}
return slug
}
// HTML2Text converts HTML to text.
func HTML2Text(html string) string {
out, err := html2text.FromString(html, html2text.Options{TextOnly: true})
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
// Markdown2HTML converts markdown to HTML, falling back to the input on error.
func Markdown2HTML(md string) string {
var b strings.Builder
if err := markdownRenderer.Convert([]byte(md), &b); err != nil {
return md
}
return b.String()
}
// SanitizeFilename sanitizes the provided filename.
func SanitizeFilename(fName string) string {
// Trim whitespace.
name := strings.TrimSpace(fName)
// Replace whitespace and "/" with "-"
name = regexpSpaces.ReplaceAllString(name, "-")
// Remove or replace any non-alphanumeric characters
name = regexpNonAlNum.ReplaceAllString(name, "")
// Convert to lowercase
name = strings.ToLower(name)
return filepath.Base(name)
}
// RandomAlphanumeric generates a random alphanumeric string of length n.
func RandomAlphanumeric(n int) (string, error) {
const dictionary = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}
// RandomNumeric generates a random numeric string of length n.
func RandomNumeric(n int) (string, error) {
const dictionary = "0123456789"
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}
// RemoveEmpty removes empty strings from a slice of strings.
func RemoveEmpty(s []string) []string {
var r []string
for _, str := range s {
if str != "" {
r = append(r, str)
}
}
return r
}
// GenerateEmailMessageID generates an RFC-compliant Message-ID for an email without angle brackets.
func GenerateEmailMessageID(uuid string, fromAddress string) (string, error) {
if uuid == "" {
return "", fmt.Errorf("uuid cannot be empty")
}
// Parse from address
addr, err := mail.ParseAddress(fromAddress)
if err != nil {
return "", fmt.Errorf("invalid from address: %w", err)
}
// Extract domain with validation
parts := strings.Split(addr.Address, "@")
if len(parts) != 2 || parts[1] == "" {
return "", fmt.Errorf("invalid domain in from address")
}
domain := parts[1]
// Random component
randomStr, err := RandomAlphanumeric(11)
if err != nil {
return "", fmt.Errorf("failed to generate random string: %w", err)
}
return fmt.Sprintf("%s-%d-%s@%s",
uuid,
time.Now().UnixNano(),
randomStr,
domain,
), nil
}
// RemoveItemByValue removes all instances of a value from a slice of strings.
func RemoveItemByValue(slice []string, value string) []string {
result := []string{}
for _, v := range slice {
if v != value {
result = append(result, v)
}
}
return result
}
// FormatDuration formats a duration as a string.
func FormatDuration(d time.Duration, includeSeconds bool) string {
d = d.Round(time.Second)
h := int64(d.Hours())
d -= time.Duration(h) * time.Hour
m := int64(d.Minutes())
d -= time.Duration(m) * time.Minute
s := int64(d.Seconds())
var parts []string
if h > 0 {
parts = append(parts, fmt.Sprintf("%d hours", h))
}
if m >= 0 {
parts = append(parts, fmt.Sprintf("%d minutes", m))
}
if s > 0 && includeSeconds {
parts = append(parts, fmt.Sprintf("%d seconds", s))
}
return strings.Join(parts, " ")
}
// ValidEmail returns true if it's a valid email else return false.
func ValidEmail(email string) bool {
addr, err := mail.ParseAddress(email)
if err != nil {
return false
}
return addr.Name == "" && addr.Address == email
}
// ExtractEmail extracts the email address from a string.
// E.g. "Name <john@example.com>" -> "john@example.com", "john@example.com" -> "john@example.com".
func ExtractEmail(s string) (string, error) {
addr, err := mail.ParseAddress(s)
if err != nil {
return "", err
}
return addr.Address, nil
}
// DedupAndExcludeString returns a deduplicated []string excluding empty and a specific value.
func DedupAndExcludeString(list []string, exclude string) []string {
seen := make(map[string]struct{}, len(list))
cleaned := make([]string, 0, len(list))
for _, s := range list {
if s == "" || s == exclude {
continue
}
if _, ok := seen[s]; !ok {
seen[s] = struct{}{}
cleaned = append(cleaned, s)
}
}
return cleaned
}
// ExtractConvUUID extracts the conversation UUID from a plus-addressed email.
// e.g., support+conv-abc12345-1234-4123-1234-123456789abc@domain.com -> abc12345-1234-4123-1234-123456789abc
// Returns empty string if no valid UUIDv4 found.
func ExtractConvUUID(email string) string {
match := regexpConvUUID.FindString(email)
if match == "" {
return ""
}
// match is "+conv-{uuid}@", extract just the UUID (skip "+conv-" prefix and "@" suffix)
return match[6 : len(match)-1]
}
// ExtractUUID finds and returns the first valid UUID v4 in the given text.
// Returns empty string if no valid UUID is found.
func ExtractUUID(text string) string {
return uuidV4Regex.FindString(text)
}
// ExtractReferenceNumber extracts the last reference number from a subject line.
// For example, "RE: Test - #392" returns "392".
// If multiple numbers exist (e.g., "Order #123 - #392"), returns the last one ("392").
func ExtractReferenceNumber(subject string) string {
matches := regexpRefNumber.FindAllStringSubmatch(subject, -1)
if len(matches) > 0 {
// Return the last match's captured group.
lastMatch := matches[len(matches)-1]
if len(lastMatch) >= 2 {
return lastMatch[1]
}
}
return ""
}
// SplitName splits a full name; the first word is the first name, the rest is the last name.
func SplitName(name string) (string, string) {
fields := strings.Fields(name)
if len(fields) == 0 {
return "", ""
}
if len(fields) == 1 {
return fields[0], ""
}
return fields[0], strings.Join(fields[1:], " ")
}