feat: upstream generic workspace tooling (board-sync, crm audit, catalog names) #76
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
var crmCmd = &cobra.Command{
|
||||
Use: "crm",
|
||||
Short: "CRM maintenance (dedupe, cleanup)",
|
||||
Short: "CRM maintenance (audit, dedupe, cleanup)",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+126
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user