feat: upstream generic workspace tooling (board-sync, crm audit, catalog names)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 1m16s
Tests / Test (Go stable) (pull_request) Successful in 1m36s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 1m16s
Tests / Test (Go stable) (pull_request) Successful in 1m36s
Promote the generic, reusable parts of the private oo-workspace into the public library/CLI, so oo-workspace can shrink to business glue. - board.go: Board types + (c *Client) SyncBoard — upsert project milestones/tasks by exact title from a YAML board (dry-run when apply=false). CLI: oo projects board-sync. - crm_audit.go: OpportunityAudit + (c *Client) AuditOpportunities — file/task/member counts with a generic class (ok|dup|empty|junk-title). CLI: oo crm audit [--out]. - catalog/names.go: CleanPersonNames / GuessNameFromEmail / FormatProjectTitle (ported from oo-workspace). - catalog: adopt the newer apply/match logic — name repair (never encode company in lastName), company grouping key, contact-info type normalization, preserve an already-applied oo_id. Keeps the local Address/OOProjects fields and the config-driven classifier. Business-only oo-workspace commands (crm clean/migrate, folders, search) deliberately stay private.
This commit is contained in:
+14
-15
@@ -115,20 +115,13 @@ func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (boo
|
||||
}
|
||||
|
||||
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||
first := strings.TrimSpace(e.First)
|
||||
last := strings.TrimSpace(e.Last)
|
||||
if first == "" && last == "" {
|
||||
first, last = SplitDisplayName(e.Name)
|
||||
}
|
||||
if first == "" {
|
||||
first = strings.TrimSpace(e.Name)
|
||||
}
|
||||
org := strings.TrimSpace(e.Org)
|
||||
first, last := CleanPersonNames(e.First, e.Last, e.Name, org, e.Emails)
|
||||
e.First, e.Last = first, last
|
||||
if first == "" {
|
||||
return false, fmt.Errorf("person missing name")
|
||||
}
|
||||
if last == "" {
|
||||
last = "-"
|
||||
}
|
||||
e.Name = strings.TrimSpace(first + " " + strings.Trim(last, "-"))
|
||||
|
||||
var p map[string]any
|
||||
var err error
|
||||
@@ -144,21 +137,27 @@ func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool
|
||||
}
|
||||
created := false
|
||||
companyID := 0
|
||||
if e.Org != "" {
|
||||
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
|
||||
if org != "" {
|
||||
if co, ferr := client.FindCompany(ctx, org); ferr == nil && co != nil {
|
||||
companyID, _ = strconv.Atoi(contactIDString(co))
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
about := ""
|
||||
if e.Org != "" {
|
||||
about = "org: " + e.Org
|
||||
if org != "" {
|
||||
about = "org: " + org
|
||||
}
|
||||
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created = true
|
||||
} else {
|
||||
// Repair names + ensure company link (never encode company in lastName).
|
||||
id := contactIDString(p)
|
||||
if _, err := client.UpdatePerson(ctx, id, first, last, companyID, "", ""); err != nil {
|
||||
return false, fmt.Errorf("update person %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
id := contactIDString(p)
|
||||
e.OOID = id
|
||||
|
||||
+9
-4
@@ -27,7 +27,7 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
||||
byEmail[NormalizeEmail(em)] = c
|
||||
}
|
||||
if isCo {
|
||||
key := NormalizeName(fmt.Sprint(c["displayName"]))
|
||||
key := onlyoffice.CompanyGroupingKey(fmt.Sprint(c["displayName"]))
|
||||
if key != "" {
|
||||
byCompanyName[key] = c
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
||||
}
|
||||
if !matched {
|
||||
if e.Kind == "company" {
|
||||
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
|
||||
if c, ok := byCompanyName[onlyoffice.CompanyGroupingKey(e.Name)]; ok {
|
||||
oo = c
|
||||
matched = true
|
||||
}
|
||||
@@ -92,6 +92,12 @@ func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Documen
|
||||
e.OOID = contactIDString(oo)
|
||||
continue
|
||||
}
|
||||
// Keep a previously applied oo_id (list payloads often omit emails, so
|
||||
// email match can miss persons that already exist in CRM).
|
||||
if strings.TrimSpace(e.OOID) != "" {
|
||||
e.Status = "exists"
|
||||
continue
|
||||
}
|
||||
e.Status = "new"
|
||||
e.OOID = ""
|
||||
}
|
||||
@@ -120,8 +126,7 @@ func contactEmails(c map[string]any) []string {
|
||||
out = append(out, em)
|
||||
}
|
||||
for _, row := range onlyoffice.ContactInfoRows(c) {
|
||||
t := strings.ToLower(fmt.Sprint(row["infoType"]))
|
||||
if t != "email" {
|
||||
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package catalog
|
||||
|
||||
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 the email local-part when the source used an address as the
|
||||
// name. Company affiliation belongs on Org / the 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)
|
||||
|
||||
// "Smith - Acme" / "Jones (Acme)" landed in last.
|
||||
last = stripCompanyAnnotation(last, org)
|
||||
if i := strings.IndexAny(first, "(["); i > 0 {
|
||||
first = strings.TrimSpace(first[:i])
|
||||
}
|
||||
// Entire last name is just the company (e.g. last="Acme").
|
||||
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 = NormalizeName(last)
|
||||
org = NormalizeName(org)
|
||||
if last == "" || org == "" {
|
||||
return false
|
||||
}
|
||||
if last == org {
|
||||
return true
|
||||
}
|
||||
// "Acme" vs "Acme GmbH & Co. KG"
|
||||
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 = NormalizeEmail(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 titleToken(local), ""
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return titleToken(parts[0]), ""
|
||||
}
|
||||
return titleToken(parts[0]), titleToken(strings.Join(parts[1:], " "))
|
||||
}
|
||||
|
||||
func titleToken(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, " | ")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanPersonNamesStripsCompanyParen(t *testing.T) {
|
||||
f, l := CleanPersonNames("John", "Smith (Acme)", "John Smith (Acme)", "Acme", nil)
|
||||
if f != "John" || l != "Smith" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesStripsDashCompany(t *testing.T) {
|
||||
f, l := CleanPersonNames("Jens", "Meyer - Acme", "", "Acme", nil)
|
||||
if f != "Jens" || l != "Meyer" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesFromEmail(t *testing.T) {
|
||||
f, l := CleanPersonNames("david.patzke@acme.example", "-", "", "Acme",
|
||||
[]string{"david.patzke@acme.example"})
|
||||
if f != "David" || l != "Patzke" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesLastIsCompany(t *testing.T) {
|
||||
f, l := CleanPersonNames("Thorsten", "Acme", "", "Acme GmbH & Co. KG", nil)
|
||||
if f != "Thorsten" || l != "-" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProjectTitle(t *testing.T) {
|
||||
if got := FormatProjectTitle("DE", "Acme", "Golang"); got != "DE | Acme | Golang" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := FormatProjectTitle("", "Acme", ""); got != "Acme" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user