feat(crm): dedupe duplicates, fix deal titles, and add cleanup CLI

Merge duplicate companies/persons/deals by name, remove duplicate contact
info and opportunity members, and rename malformed " @ Company" titles.
Applications sync now avoids empty-position titles and duplicate members.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 12:34:14 +01:00
co-authored by Cursor
parent 1b238aca5a
commit b79c097874
19 changed files with 1782 additions and 31 deletions
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
"opportunities", "cases", "crm-tasks", "applications", "crm",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
got[c.Name()] = true
}
for _, name := range want {
if !got[name] {
t.Fatalf("missing root subcommand %q; have %v", name, rootCmd.Commands())
}
}
}
func TestRootHelpListsSubjects(t *testing.T) {
out := &bytes.Buffer{}
rootCmd.SetOut(out)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs([]string{"--help"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
}
}
func TestProjectsAlias(t *testing.T) {
cmd, _, err := rootCmd.Find([]string{"prj"})
if err != nil {
t.Fatal(err)
}
if cmd.Name() != "projects" {
t.Fatalf("prj alias resolved to %q", cmd.Name())
}
}
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
errBuf := &bytes.Buffer{}
rootCmd.SetErr(errBuf)
rootCmd.SetOut(&bytes.Buffer{})
rootCmd.SetArgs([]string{"users", "list"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
err = rootCmd.Execute()
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
+23 -3
View File
@@ -32,13 +32,13 @@ func init() {
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// newOO loads env (only .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
_ = godotenv.Load()
loadOOEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
@@ -48,6 +48,26 @@ func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
return c, nil
}
func loadOOEnv() {
_ = godotenv.Load(".env")
applyOOEnvAliases()
}
func applyOOEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
// printJSON dumps any value as indented JSON.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestLoadOOEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
loadOOEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestApplyOOEnvAliasesDoesNotOverrideCanonical(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
applyOOEnvAliases()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestFmtCell(t *testing.T) {
tests := []struct {
in any
want string
}{
{nil, ""},
{"hello", "hello"},
{true, "true"},
{false, "false"},
{float64(42), "42"},
{float64(1.5), "1.5"},
{int64(7), "7"},
{map[string]any{"a": 1}, `{"a":1}`},
}
for _, tc := range tests {
if got := fmtCell(tc.in); got != tc.want {
t.Fatalf("fmtCell(%#v)=%q want %q", tc.in, got, tc.want)
}
}
long := strings.Repeat("x", 100)
got := fmtCell(long)
if !strings.HasSuffix(got, "…") || len(got) > 82 {
t.Fatalf("fmtCell(long)=%q len=%d", got, len(got))
}
}
func TestTruncate(t *testing.T) {
if got := truncate("a\nb", 10); got != "a b" {
t.Fatalf("truncate newline=%q", got)
}
got := truncate(strings.Repeat("z", 20), 10)
if !strings.HasSuffix(got, "…") || len(got) >= 20 {
t.Fatalf("truncate=%q len=%d", got, len(got))
}
}
func TestSortedKeys(t *testing.T) {
got := sortedKeys(map[string]any{"c": 1, "a": 2, "b": 3})
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortedKeys=%v want %v", got, want)
}
}
}
func TestFlexIDFloat(t *testing.T) {
if got := flexIDFloat(float64(12)); got != 12 {
t.Fatalf("float64=%v", got)
}
if got := flexIDFloat(7); got != 7 {
t.Fatalf("int=%v", got)
}
if got := flexIDFloat("3.5"); got != 3.5 {
t.Fatalf("string=%v", got)
}
}
func TestIdString(t *testing.T) {
m := map[string]any{"id": float64(99), "s": "x", "i": 5}
if got := idString(m, "id"); got != "99" {
t.Fatalf("float id=%q", got)
}
if got := idString(m, "s"); got != "x" {
t.Fatalf("string id=%q", got)
}
if got := idString(m, "i"); got != "5" {
t.Fatalf("int id=%q", got)
}
if got := idString(m, "missing"); got != "" {
t.Fatalf("missing=%q", got)
}
}
func TestPrintTableJSON(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "json"
out := captureStdout(t, func() {
printTable([]string{"id", "name"}, []map[string]any{{"id": 1, "name": "alpha"}})
})
if !strings.Contains(out, `"id": 1`) || !strings.Contains(out, `"name": "alpha"`) {
t.Fatalf("json output=%q", out)
}
}
func TestPrintTableEmpty(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() { printTable([]string{"id"}, nil) })
if strings.TrimSpace(out) != "(empty)" {
t.Fatalf("empty table=%q", out)
}
}
func TestPrintObjectTable(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() {
printObject(map[string]any{"id": float64(1), "title": "Demo"})
})
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "Demo") {
t.Fatalf("table object=%q", out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
_ = r.Close()
return buf.String()
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+65
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
@@ -39,16 +40,20 @@ func init() {
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
onlyCo := false
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
companiesCmd.AddCommand(companiesCreateCmd())
companiesCmd.AddCommand(contactsDeleteCmd())
companiesCmd.AddCommand(companiesDedupeCmd())
companiesCmd.AddCommand(companiesDedupePersonsCmd())
}
// contactsListCmd returns a `list` subcommand.
@@ -246,3 +251,63 @@ func companiesCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
return cmd
}
func companiesDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate companies by normalized name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanies(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func companiesDedupePersonsCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-persons",
Short: "Merge duplicate persons under each company",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanyPersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func personsDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate persons by normalized first+last name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupePersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func contactsDedupeInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-info",
Short: "Remove duplicate contact info rows (email, phone, …)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeContactInfo(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var crmCmd = &cobra.Command{
Use: "crm",
Short: "CRM maintenance (dedupe, cleanup)",
}
func init() {
rootCmd.AddCommand(crmCmd)
crmCmd.AddCommand(crmCleanupCmd())
}
func crmCleanupCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Run all CRM dedupe passes (companies, persons, associations, titles)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := onlyoffice.CleanupCRM(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group deals by position only (strip ' @ Company')")
return cmd
}
func dedupeRunE(fn func(cmd *cobra.Command, c *onlyoffice.Client) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
return fn(cmd, c)
}
}
+5 -4
View File
@@ -6,12 +6,13 @@
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add
// oo persons list | create | delete
// oo companies list | create | delete
// oo opportunities list | get | create | delete | stages | member-add
// oo contacts list | get | delete | info-add | dedupe-info
// oo persons list | create | delete | dedupe
// oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories
// oo crm cleanup
// oo applications sync
//
// Every list supports `--output/-o json|table` (table is the default).
+52
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
@@ -21,6 +22,9 @@ func init() {
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
opportunitiesCmd.AddCommand(oppDedupeCmd())
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
}
func oppListCmd() *cobra.Command {
@@ -175,3 +179,51 @@ func oppMemberAddCmd() *cobra.Command {
},
}
}
func oppDedupeCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate opportunities by title",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunities(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group by position only")
return cmd
}
func oppDedupeMembersCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-members",
Short: "Remove duplicate contacts on each deal (by id and display name)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunityMembers(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func oppFixTitlesCmd() *cobra.Command {
return &cobra.Command{
Use: "fix-titles",
Short: "Fix malformed deal titles (leading @, spacing)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.FixOpportunityTitles(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}