Move catalog, dossier sync, and SearXNG to private oo-workspace; keep generic CRM name/title helpers and anonymized docs/examples.
173 lines
4.7 KiB
Go
173 lines
4.7 KiB
Go
package onlyoffice
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
var (
|
||
parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`)
|
||
dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`)
|
||
emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||
nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`)
|
||
)
|
||
|
||
// CleanPersonNames strips company annotations from display names and fills
|
||
// first/last from email local-part when the source used an address as the name.
|
||
// Company affiliation belongs on CRM companyId — never in LastName.
|
||
func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) {
|
||
first = strings.TrimSpace(first)
|
||
last = strings.TrimSpace(last)
|
||
display = strings.TrimSpace(display)
|
||
org = strings.TrimSpace(org)
|
||
|
||
if looksLikeEmail(first) {
|
||
ef, el := GuessNameFromEmail(first)
|
||
first, last = ef, el
|
||
}
|
||
if looksLikeEmail(display) && first == "" && last == "" {
|
||
display = ""
|
||
}
|
||
|
||
if first == "" && last == "" && display != "" {
|
||
first, last = SplitDisplayName(display)
|
||
}
|
||
|
||
first = stripCompanyAnnotation(first, org)
|
||
last = stripCompanyAnnotation(last, org)
|
||
|
||
last = stripCompanyAnnotation(last, org)
|
||
if i := strings.IndexAny(first, "(["); i > 0 {
|
||
first = strings.TrimSpace(first[:i])
|
||
}
|
||
if org != "" && personLastIsOrg(last, org) {
|
||
last = ""
|
||
}
|
||
|
||
if (first == "" || looksLikeEmail(first)) && len(emails) > 0 {
|
||
ef, el := GuessNameFromEmail(emails[0])
|
||
if first == "" || looksLikeEmail(first) {
|
||
first = ef
|
||
}
|
||
if last == "" || last == "-" {
|
||
last = el
|
||
}
|
||
}
|
||
|
||
first = strings.TrimSpace(first)
|
||
last = strings.TrimSpace(last)
|
||
if last == "" {
|
||
last = "-"
|
||
}
|
||
return first, last
|
||
}
|
||
|
||
func stripCompanyAnnotation(s, org string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
s = parenSuffixRE.ReplaceAllString(s, "")
|
||
s = strings.TrimSpace(s)
|
||
s = dashCompanyRE.ReplaceAllString(s, "")
|
||
s = strings.TrimSpace(s)
|
||
if org != "" {
|
||
for _, sep := range []string{" - ", " – ", " — ", " / "} {
|
||
if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 {
|
||
s = strings.TrimSpace(s[:i])
|
||
}
|
||
}
|
||
suf := " (" + org + ")"
|
||
if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) {
|
||
s = strings.TrimSpace(s[:len(s)-len(suf)])
|
||
}
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func personLastIsOrg(last, org string) bool {
|
||
last = NormalizePersonNameKey(last)
|
||
org = NormalizePersonNameKey(org)
|
||
if last == "" || org == "" {
|
||
return false
|
||
}
|
||
if last == org {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",")
|
||
}
|
||
|
||
func looksLikeEmail(s string) bool {
|
||
return emailLocalRE.MatchString(strings.TrimSpace(s))
|
||
}
|
||
|
||
// GuessNameFromEmail turns local@domain into Title-Case first/last when the
|
||
// local part looks like first.last / first_last / first-last.
|
||
func GuessNameFromEmail(email string) (first, last string) {
|
||
email = NormalizeContactEmail(email)
|
||
local, _, ok := strings.Cut(email, "@")
|
||
if !ok || local == "" {
|
||
return "", ""
|
||
}
|
||
local = strings.Split(local, "+")[0]
|
||
parts := strings.FieldsFunc(local, func(r rune) bool {
|
||
return r == '.' || r == '_' || r == '-'
|
||
})
|
||
if len(parts) == 0 {
|
||
return titleNameToken(local), ""
|
||
}
|
||
if len(parts) == 1 {
|
||
return titleNameToken(parts[0]), ""
|
||
}
|
||
return titleNameToken(parts[0]), titleNameToken(strings.Join(parts[1:], " "))
|
||
}
|
||
|
||
func titleNameToken(s string) string {
|
||
s = nonNameTokenRE.ReplaceAllString(s, " ")
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
runes := []rune(strings.ToLower(s))
|
||
runes[0] = unicode.ToTitle(runes[0])
|
||
return string(runes)
|
||
}
|
||
|
||
// FormatProjectTitle builds "CC | Company | Title" (spaces around |).
|
||
// Country should be a short code (DE, TF, UA, …). Empty segments are dropped.
|
||
func FormatProjectTitle(country, company, title string) string {
|
||
parts := make([]string, 0, 3)
|
||
for _, p := range []string{country, company, title} {
|
||
p = strings.TrimSpace(p)
|
||
p = strings.ReplaceAll(p, "|", "/")
|
||
if p != "" {
|
||
parts = append(parts, p)
|
||
}
|
||
}
|
||
return strings.Join(parts, " | ")
|
||
}
|
||
|
||
// NormalizeContactEmail lowercases and trims.
|
||
func NormalizeContactEmail(s string) string {
|
||
return strings.ToLower(strings.TrimSpace(s))
|
||
}
|
||
|
||
// NormalizePersonNameKey collapses whitespace and lowercases for matching.
|
||
func NormalizePersonNameKey(s string) string {
|
||
fields := strings.Fields(strings.ToLower(strings.TrimSpace(s)))
|
||
return strings.Join(fields, " ")
|
||
}
|
||
|
||
// SplitDisplayName splits "First Last …" into first/last (last = remainder).
|
||
func SplitDisplayName(name string) (first, last string) {
|
||
parts := strings.Fields(strings.TrimSpace(name))
|
||
if len(parts) == 0 {
|
||
return "", ""
|
||
}
|
||
if len(parts) == 1 {
|
||
return parts[0], ""
|
||
}
|
||
return parts[0], strings.Join(parts[1:], " ")
|
||
}
|