diff --git a/README.md b/README.md index 7c6a12b..756b806 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,8 @@ type Task struct { | `SignJWT(secret, payload)` | HS256 JWT, stdlib only | | `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) | | `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer | +| `SyncBoard(ctx, board, apply)` | Upsert project milestones/tasks from a YAML board (dry-run when apply=false) | +| `AuditOpportunities(ctx)` | Opportunities with file/task/member counts and a coarse class | | `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook | | `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) | | `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) | @@ -823,7 +825,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV] | Subject | Verbs | |---|---| | `calendar` | `list`, `events`, `add`, `delete` | -| `projects` | `list`, `get`, `milestones`, `milestone-create`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) | +| `projects` | `list`, `get`, `milestones`, `milestone-create`, `board-sync`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) | | `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) | | `users` | `list`, `self` (alias: `oo whoami`) | | `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` | @@ -831,7 +833,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV] | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` | -| `crm` | `cleanup` | +| `crm` | `audit`, `cleanup` | | `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` | | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` | diff --git a/board.go b/board.go new file mode 100644 index 0000000..6f0142a --- /dev/null +++ b/board.go @@ -0,0 +1,205 @@ +package onlyoffice + +// Project board (Gantt) upsert from a YAML board file. +// +// A board describes projects, their milestones and tasks by exact title. Sync +// creates only what is missing: existing milestones/tasks (matched by title) +// are left untouched, so the file can be the source of truth for a project +// plan and re-applied safely. Dry-run (apply=false) reports counts without +// writing. +// +// The file format is deliberately small and presentation-free: +// +// projects: +// - id: 42 +// name: "Example" +// milestones: +// - title: "Kickoff" +// deadline: "2026-01-15" +// key: true +// tasks: +// - title: "Draft" +// start: "2026-01-02" +// deadline: "2026-01-10" +// description: "…" + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Board is a YAML mapping from a project plan onto OnlyOffice milestones/tasks. +type Board struct { + Projects []BoardProject `yaml:"projects"` +} + +// BoardProject is one project with its milestones. +type BoardProject struct { + ID int `yaml:"id"` + Name string `yaml:"name,omitempty"` + Milestones []BoardMilestone `yaml:"milestones"` +} + +// BoardMilestone is a milestone ("key" marks it as a key milestone). +type BoardMilestone struct { + Title string `yaml:"title"` + Deadline string `yaml:"deadline"` + Key bool `yaml:"key,omitempty"` + Tasks []BoardTask `yaml:"tasks"` +} + +// BoardTask is a task inside a milestone. Start falls back to Deadline. +type BoardTask struct { + Title string `yaml:"title"` + Start string `yaml:"start,omitempty"` + Deadline string `yaml:"deadline"` + Description string `yaml:"description,omitempty"` +} + +// BoardSyncResult counts what SyncBoard created or skipped. +type BoardSyncResult struct { + CreatedMilestones int + SkippedMilestones int + CreatedTasks int + SkippedTasks int + DryRun bool +} + +// LoadBoard reads a board YAML file. +func LoadBoard(path string) (*Board, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseBoard(b) +} + +// ParseBoard decodes a board from YAML bytes. +func ParseBoard(data []byte) (*Board, error) { + var board Board + if err := yaml.Unmarshal(data, &board); err != nil { + return nil, fmt.Errorf("board: %w", err) + } + if len(board.Projects) == 0 { + return nil, fmt.Errorf("board: no projects") + } + return &board, nil +} + +func boardDay(s string) (Time, error) { + t, err := time.Parse("2006-01-02", s) + if err != nil { + return Time{}, err + } + return Time(t), nil +} + +func boardMilestoneIDs(ms []*Milestone) map[string]int64 { + out := map[string]int64{} + for _, m := range ms { + if m == nil || m.Title == nil || m.ID == nil { + continue + } + out[*m.Title] = *m.ID + } + return out +} + +func boardTaskTitles(rows []map[string]any) map[string]struct{} { + out := map[string]struct{}{} + for _, r := range rows { + if t, _ := r["title"].(string); t != "" { + out[t] = struct{}{} + } + } + return out +} + +// SyncBoard upserts milestones and tasks by exact title. With apply=false it +// only counts what would be created. +func (c *Client) SyncBoard(ctx context.Context, board *Board, apply bool) (*BoardSyncResult, error) { + if board == nil || len(board.Projects) == 0 { + return nil, fmt.Errorf("board: no projects") + } + res := &BoardSyncResult{DryRun: !apply} + for _, p := range board.Projects { + pid := p.ID + existing, err := c.GetProjectMilestones(&Project{ID: &pid}) + if err != nil { + return res, fmt.Errorf("project %d milestones: %w", pid, err) + } + haveMS := boardMilestoneIDs(existing) + tasks, err := c.ListTasks(ctx, strconv.Itoa(pid), "") + if err != nil { + return res, fmt.Errorf("project %d tasks: %w", pid, err) + } + haveTask := boardTaskTitles(tasks) + + for _, m := range p.Milestones { + msID, ok := haveMS[m.Title] + if !ok { + res.CreatedMilestones++ + if apply { + dl, err := boardDay(m.Deadline) + if err != nil { + return res, fmt.Errorf("milestone %q deadline: %w", m.Title, err) + } + created, err := c.CreateMilestone(NewMilestoneRequest{ + ProjectID: pid, + Title: m.Title, + Deadline: dl, + IsKey: m.Key, + }) + if err != nil { + return res, fmt.Errorf("create milestone %q: %w", m.Title, err) + } + if created.ID != nil { + msID = *created.ID + } + haveMS[m.Title] = msID + } + } else { + res.SkippedMilestones++ + } + for _, t := range m.Tasks { + if _, exists := haveTask[t.Title]; exists { + res.SkippedTasks++ + continue + } + res.CreatedTasks++ + if !apply { + continue + } + start := t.Start + if start == "" { + start = t.Deadline + } + st, err := boardDay(start) + if err != nil { + return res, fmt.Errorf("task %q start: %w", t.Title, err) + } + dl, err := boardDay(t.Deadline) + if err != nil { + return res, fmt.Errorf("task %q deadline: %w", t.Title, err) + } + if _, err := c.CreateProjectTask(NewProjectTaskRequest{ + ProjectId: pid, + Title: t.Title, + Description: t.Description, + StartDate: st, + Deadline: dl, + MilestoneId: int(msID), + }); err != nil { + return res, fmt.Errorf("create task %q: %w", t.Title, err) + } + haveTask[t.Title] = struct{}{} + } + } + } + return res, nil +} diff --git a/board_test.go b/board_test.go new file mode 100644 index 0000000..e087406 --- /dev/null +++ b/board_test.go @@ -0,0 +1,70 @@ +package onlyoffice + +import "testing" + +func TestParseBoard(t *testing.T) { + b, err := ParseBoard([]byte(` +projects: + - id: 13 + name: Example + milestones: + - title: "[lq] Test" + deadline: "2026-01-15" + key: true + tasks: + - title: Draft + start: "2026-01-02" + deadline: "2026-01-10" + description: "…" +`)) + if err != nil { + t.Fatal(err) + } + if len(b.Projects) != 1 || b.Projects[0].ID != 13 { + t.Fatalf("projects: %+v", b.Projects) + } + ms := b.Projects[0].Milestones[0] + if ms.Title != "[lq] Test" || !ms.Key || ms.Deadline != "2026-01-15" { + t.Fatalf("milestone: %+v", ms) + } + if len(ms.Tasks) != 1 || ms.Tasks[0].Title != "Draft" || ms.Tasks[0].Start != "2026-01-02" { + t.Fatalf("task: %+v", ms.Tasks) + } +} + +func TestParseBoardEmpty(t *testing.T) { + if _, err := ParseBoard([]byte("projects: []")); err == nil { + t.Fatal("expected error for empty board") + } +} + +func TestBoardMilestoneIDs(t *testing.T) { + title := "[lq] Test" + id := int64(9) + got := boardMilestoneIDs([]*Milestone{{Title: &title, ID: &id}, nil}) + if got[title] != 9 { + t.Fatalf("%v", got) + } +} + +func TestBoardTaskTitles(t *testing.T) { + got := boardTaskTitles([]map[string]any{{"title": "a"}, {"title": "b"}, {"nope": 1}}) + if _, ok := got["a"]; !ok { + t.Fatal("missing a") + } + if _, ok := got["b"]; !ok { + t.Fatal("missing b") + } + if len(got) != 2 { + t.Fatalf("%v", got) + } +} + +func TestBoardDay(t *testing.T) { + if _, err := boardDay("2026-01-15"); err != nil { + t.Fatal(err) + } + if _, err := boardDay("15.01.2026"); err == nil { + t.Fatal("expected error for non-ISO date") + } +} diff --git a/catalog/apply.go b/catalog/apply.go index 2f5094e..1546abf 100644 --- a/catalog/apply.go +++ b/catalog/apply.go @@ -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 diff --git a/catalog/match.go b/catalog/match.go index c398c5e..c506658 100644 --- a/catalog/match.go +++ b/catalog/match.go @@ -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"])) diff --git a/catalog/names.go b/catalog/names.go new file mode 100644 index 0000000..6f60837 --- /dev/null +++ b/catalog/names.go @@ -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, " | ") +} diff --git a/catalog/names_test.go b/catalog/names_test.go new file mode 100644 index 0000000..a42dea9 --- /dev/null +++ b/catalog/names_test.go @@ -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) + } +} diff --git a/cmd/oo/crm.go b/cmd/oo/crm.go index 7514adc..7889a65 100644 --- a/cmd/oo/crm.go +++ b/cmd/oo/crm.go @@ -7,7 +7,7 @@ import ( var crmCmd = &cobra.Command{ Use: "crm", - Short: "CRM maintenance (dedupe, cleanup)", + Short: "CRM maintenance (audit, dedupe, cleanup)", } func init() { diff --git a/cmd/oo/crm_audit.go b/cmd/oo/crm_audit.go new file mode 100644 index 0000000..039d79a --- /dev/null +++ b/cmd/oo/crm_audit.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + "github.com/spf13/cobra" +) + +func init() { + crmCmd.AddCommand(crmAuditCmd()) +} + +func crmAuditCmd() *cobra.Command { + var outPath string + cmd := &cobra.Command{ + Use: "audit", + Short: "Audit opportunities (files/tasks/members per deal) and classify", + Long: `Lists every opportunity with its file/task/member counts and a coarse class +(ok | dup | empty | junk-title). --out writes the full JSON audit for later use.`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + audits, err := c.AuditOpportunities(cmd.Context()) + if err != nil { + return err + } + if outPath != "" { + b, merr := json.MarshalIndent(audits, "", " ") + if merr != nil { + return merr + } + if werr := os.WriteFile(outPath, b, 0o644); werr != nil { + return werr + } + } + byClass := map[string]int{} + for _, a := range audits { + byClass[a.Class]++ + } + classes := make([]string, 0, len(byClass)) + for k := range byClass { + classes = append(classes, k) + } + sort.Strings(classes) + rows := make([]map[string]any, 0, len(classes)) + for _, k := range classes { + rows = append(rows, map[string]any{"class": k, "count": byClass[k]}) + } + printTable([]string{"class", "count"}, rows) + if outPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "wrote %d audits → %s\n", len(audits), outPath) + } + return nil + }, + } + cmd.Flags().StringVar(&outPath, "out", "", "write audit JSON to this file") + return cmd +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 7e41451..7f52849 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -3,7 +3,7 @@ // Command tree is subject-based (mirrors the library split and the `tea` CLI): // // oo calendar list | events | add | delete -// oo projects list | get | milestones | milestone-create | milestone-delete | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) +// oo projects list | get | milestones | milestone-create | milestone-delete | board-sync | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) // oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach) // oo users list | self | get | create | update | delete | block | unblock | password | check (alias: oo whoami) // oo link FILE_ID [FILE_ID...] DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…) @@ -13,7 +13,7 @@ // oo opportunities list | get | create | update | delete | stages | member-add | dedupe | dedupe-members | fix-titles // oo cases list | create | delete | member-add // oo crm-tasks list | create | delete | categories | reassign-self -// oo crm cleanup +// oo crm audit | cleanup // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … // oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx diff --git a/cmd/oo/projects_board.go b/cmd/oo/projects_board.go new file mode 100644 index 0000000..3a865b7 --- /dev/null +++ b/cmd/oo/projects_board.go @@ -0,0 +1,54 @@ +package main + +import ( + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +func init() { + projectsCmd.AddCommand(prjBoardSyncCmd()) +} + +// prjBoardSyncCmd upserts project milestones/tasks from a board YAML. +func prjBoardSyncCmd() *cobra.Command { + var apply bool + cmd := &cobra.Command{ + Use: "board-sync BOARD.yaml", + Short: "Upsert project milestones/tasks from a board YAML (dry-run by default)", + Long: `Reads a board YAML (projects → milestones → tasks) and creates only the +milestones/tasks that are missing, matching by exact title. Existing entries are +left untouched, so the same file can be re-applied safely. + +Dry-run by default; pass --apply to write to OnlyOffice.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + board, err := onlyoffice.LoadBoard(args[0]) + if err != nil { + return err + } + c, err := newOO(cmd) + if err != nil { + return err + } + res, err := c.SyncBoard(cmd.Context(), board, apply) + if err != nil { + return err + } + mode := "dry-run" + if apply { + mode = "apply" + } + printObject(map[string]any{ + "mode": mode, + "projects": len(board.Projects), + "created_milestones": res.CreatedMilestones, + "skipped_milestones": res.SkippedMilestones, + "created_tasks": res.CreatedTasks, + "skipped_tasks": res.SkippedTasks, + }) + return nil + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "write to OnlyOffice (default: dry-run)") + return cmd +} diff --git a/crm_audit.go b/crm_audit.go new file mode 100644 index 0000000..aac9a73 --- /dev/null +++ b/crm_audit.go @@ -0,0 +1,126 @@ +package onlyoffice + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +// OpportunityAudit is one CRM opportunity with its resource counts and a coarse +// class, for hygiene reporting (see `oo crm audit`). +type OpportunityAudit struct { + ID int64 `json:"id"` + Title string `json:"title"` + Created string `json:"created,omitempty"` + Files int `json:"files"` + OpenTasks int `json:"open_tasks"` + ClosedTasks int `json:"closed_tasks"` + Members int `json:"members"` + GroupKey string `json:"group_key,omitempty"` + Class string `json:"class"` // ok | dup | empty | junk-title +} + +// AuditOpportunities lists every opportunity with file/task/member counts and a +// coarse class. The classification is generic and rule-free: +// +// dup — another opportunity shares the same title key +// empty — no files, tasks or members +// junk-title — title is not of the "Role @ Company" shape +// ok — everything else +// +// Callers that need stricter business rules can post-process the result. +func (c *Client) AuditOpportunities(ctx context.Context) ([]OpportunityAudit, error) { + deals, err := c.ListAllOpportunities(ctx) + if err != nil { + return nil, err + } + tasks, _, err := c.ListCRMTasks(ctx, 5000, 0) + if err != nil { + return nil, err + } + open, closed := taskCountsByOpportunity(tasks) + + out := make([]OpportunityAudit, 0, len(deals)) + for _, row := range deals { + id := auditID(row["id"]) + if id == 0 { + continue + } + title := auditStr(row["title"]) + files := 0 + if fl, ferr := c.ListOpportunityFiles(ctx, strconv.FormatInt(id, 10)); ferr == nil { + files = len(fl) + } + key := strconv.FormatInt(id, 10) + out = append(out, OpportunityAudit{ + ID: id, + Title: title, + Created: auditStr(row["created"]), + Files: files, + OpenTasks: open[key], + ClosedTasks: closed[key], + Members: len(OpportunityMembers(row)), + GroupKey: DealTitleKey(title, false), + }) + } + + groupCount := map[string]int{} + for _, a := range out { + groupCount[a.GroupKey]++ + } + for i := range out { + a := &out[i] + switch { + case groupCount[a.GroupKey] > 1: + a.Class = "dup" + case a.Files == 0 && a.OpenTasks == 0 && a.ClosedTasks == 0 && a.Members == 0: + a.Class = "empty" + case !strings.Contains(a.Title, "@") || strings.HasPrefix(strings.TrimSpace(a.Title), "@"): + a.Class = "junk-title" + default: + a.Class = "ok" + } + } + return out, nil +} + +// taskCountsByOpportunity buckets CRM task statuses per opportunity id. +func taskCountsByOpportunity(tasks []map[string]any) (open, closed map[string]int) { + open, closed = map[string]int{}, map[string]int{} + for _, t := range tasks { + ent, ok := t["entity"].(map[string]any) + if !ok || auditStr(ent["entityType"]) != "opportunity" { + continue + } + eid := auditStr(ent["entityId"]) + status := strings.ToLower(auditStr(t["status"])) + if status == "2" || status == "closed" { + closed[eid]++ + } else { + open[eid]++ + } + } + return open, closed +} + +func auditID(v any) int64 { + switch x := v.(type) { + case float64: + return int64(x) + case int: + return int64(x) + case int64: + return x + default: + n, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(x)), 10, 64) + return n + } +} + +func auditStr(v any) string { + if v == nil { + return "" + } + return fmt.Sprint(v) +} diff --git a/crm_audit_test.go b/crm_audit_test.go new file mode 100644 index 0000000..e34452f --- /dev/null +++ b/crm_audit_test.go @@ -0,0 +1,36 @@ +package onlyoffice + +import "testing" + +func TestAuditID(t *testing.T) { + cases := []struct { + in any + want int64 + }{ + {float64(12), 12}, + {7, 7}, + {int64(9), 9}, + {"42", 42}, + {nil, 0}, + } + for _, c := range cases { + if got := auditID(c.in); got != c.want { + t.Fatalf("auditID(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestTaskCountsByOpportunity(t *testing.T) { + open, closed := taskCountsByOpportunity([]map[string]any{ + {"id": 1, "status": 1, "entity": map[string]any{"entityType": "opportunity", "entityId": 10}}, + {"id": 2, "status": "2", "entity": map[string]any{"entityType": "opportunity", "entityId": 10}}, + {"id": 3, "status": 1, "entity": map[string]any{"entityType": "contact", "entityId": 10}}, + {"id": 4, "entity": "not-a-map"}, + }) + if open["10"] != 1 || closed["10"] != 1 { + t.Fatalf("open=%v closed=%v", open, closed) + } + if len(open) != 1 || len(closed) != 1 { + t.Fatalf("unexpected buckets: open=%v closed=%v", open, closed) + } +}