Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1624920e | ||
|
|
1bf417d70f | ||
|
|
aa79a83c0a | ||
|
|
179f30644d | ||
|
|
b334f9ff86 | ||
|
|
e07cf743f9 | ||
|
|
8b764a36a1 | ||
|
|
aa4e71b9d2 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.8.3"
|
".": "0.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
||||||
- `main.go` — entry point (docstring lists the command tree).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
||||||
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
|
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go`, `catalog.go` — one file per subject (or per subject facet), each registers in `init()`.
|
||||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
||||||
|
- **Catalog inventory — `catalog/` package.** Filesystem VCF/project scan → YAML → match/apply against CRM. Used by `oo catalog`; not part of the flat `*Client` surface (uses Client as a dependency).
|
||||||
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
||||||
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
||||||
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ All notable changes to this project are documented here. The format is based on
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **catalog:** mbox header scan, company legal-suffix match, apply order ([8b29be2](https://github.com/eSlider/go-onlyoffice/commit/8b29be2d9bb71154d473f780ad247c5c440c6021))
|
||||||
|
* **catalog:** scan Thunderbird address books and Gloda contacts ([502b373](https://github.com/eSlider/go-onlyoffice/commit/502b373e3726ded8b9c50e061ff30aea83205a34))
|
||||||
|
* **oo:** catalog scan/match/apply for CRM clients and contacts ([547f063](https://github.com/eSlider/go-onlyoffice/commit/547f063f7fe922fe784358093a89730f9aaf0683))
|
||||||
|
* **oo:** catalog scan/match/apply for CRM clients and contacts ([c32e4dd](https://github.com/eSlider/go-onlyoffice/commit/c32e4dd6116f0b8df67e93c5b589a417dfee59aa))
|
||||||
|
* **projects:** CRM contacts + git author linking ([457c589](https://github.com/eSlider/go-onlyoffice/commit/457c589abdc2c9fe8edd42b4b7445581976cc5a2))
|
||||||
|
* **projects:** link CRM companies and git authors to projects ([5a8735e](https://github.com/eSlider/go-onlyoffice/commit/5a8735e2898cf53e330e183250cf3c5d3c84da55))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **catalog:** oo_projects field + safer match ([8d5ca67](https://github.com/eSlider/go-onlyoffice/commit/8d5ca67d64da83c70d8ae293f4c7c23f9f9f59d3))
|
||||||
|
* **catalog:** preserve oo_id on match; add oo_projects field ([d873cb7](https://github.com/eSlider/go-onlyoffice/commit/d873cb748adf4458369d8a2167abd8da987737c5))
|
||||||
|
* **crm:** clean person names; canonical project titles ([b9591f5](https://github.com/eSlider/go-onlyoffice/commit/b9591f5fc3efc13b78dbe932ff32c2f2905e18fc))
|
||||||
|
* **crm:** person names + CC | Company | Title projects ([6305135](https://github.com/eSlider/go-onlyoffice/commit/6305135bd799efe42dbb900a694e82aeae9f9e58))
|
||||||
|
|
||||||
## [0.8.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
|
## [0.8.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -635,6 +635,7 @@ oo tasks files detach 208 12345
|
|||||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||||
| `applications` | `sync` |
|
| `applications` | `sync` |
|
||||||
|
| `catalog` | `scan-contacts`, `scan-projects`, `scan-thunderbird`, `merge`, `match`, `apply` |
|
||||||
|
|
||||||
The CLI reads only `.env` from the current working directory (godotenv is a
|
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||||
CLI-only concern — the library itself never loads dotfiles).
|
CLI-only concern — the library itself never loads dotfiles).
|
||||||
@@ -733,6 +734,27 @@ oo contacts get 857 -o json
|
|||||||
oo crm cleanup -o json
|
oo crm cleanup -o json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Clients/contacts inventory (`catalog`)
|
||||||
|
|
||||||
|
**Problem:** Contacts live as VCF/folders on disk and project trees under
|
||||||
|
`~/work` / ops-host; OnlyOffice CRM is the SSOT for persons/companies but
|
||||||
|
starts sparse. You need a reviewable inventory before writes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oo catalog scan-contacts --root /path/to/contacts -O /tmp/contacts.yaml
|
||||||
|
oo catalog scan-projects --root ~/work -O /tmp/projects.yaml
|
||||||
|
oo catalog scan-thunderbird --root /path/to/thunderbird-profile -O /tmp/thunderbird.yaml
|
||||||
|
oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O clients-contacts.yaml
|
||||||
|
oo catalog match -i clients-contacts.yaml
|
||||||
|
# set approve: true on pilot rows in the YAML
|
||||||
|
oo catalog apply --dry-run -i clients-contacts.yaml
|
||||||
|
oo catalog apply --apply -i clients-contacts.yaml
|
||||||
|
oo crm cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
YAML schema and ops runbook live in the inventar repo under `docs/catalog/` and
|
||||||
|
`docs/ops/oo-clients-contacts-sync.md`. Library helper: `FindPersonByEmail`.
|
||||||
|
|
||||||
### Job applications → CRM (`applications sync`)
|
### Job applications → CRM (`applications sync`)
|
||||||
|
|
||||||
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
|
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
"calendar", "projects", "tasks", "users", "whoami",
|
"calendar", "projects", "tasks", "users", "whoami",
|
||||||
"contacts", "persons", "companies",
|
"contacts", "persons", "companies",
|
||||||
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails",
|
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog",
|
||||||
}
|
}
|
||||||
got := make(map[string]bool, len(rootCmd.Commands()))
|
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||||
for _, c := range rootCmd.Commands() {
|
for _, c := range rootCmd.Commands() {
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ var outputFormat = "table"
|
|||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "oo",
|
Use: "oo",
|
||||||
Short: "OnlyOffice Workspace CLI — subject-based command tree",
|
Short: "OnlyOffice Workspace CLI — subject-based command tree",
|
||||||
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).",
|
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog).",
|
||||||
Version: version,
|
Version: version,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/catalog"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,12 +41,14 @@ func init() {
|
|||||||
contactsCmd.AddCommand(contactsListCmd(nil))
|
contactsCmd.AddCommand(contactsListCmd(nil))
|
||||||
contactsCmd.AddCommand(contactsGetCmd())
|
contactsCmd.AddCommand(contactsGetCmd())
|
||||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||||
|
contactsCmd.AddCommand(contactsMergeCmd())
|
||||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||||
|
|
||||||
only := true
|
only := true
|
||||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||||
personsCmd.AddCommand(personsCreateCmd())
|
personsCmd.AddCommand(personsCreateCmd())
|
||||||
|
personsCmd.AddCommand(personsFixNamesCmd())
|
||||||
personsCmd.AddCommand(contactsDeleteCmd())
|
personsCmd.AddCommand(contactsDeleteCmd())
|
||||||
personsCmd.AddCommand(personsDedupeCmd())
|
personsCmd.AddCommand(personsDedupeCmd())
|
||||||
|
|
||||||
@@ -146,6 +150,26 @@ func contactsDeleteCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactsMergeCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "merge FROM_ID INTO_ID",
|
||||||
|
Short: "Merge FROM contact into INTO (FROM is removed)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.MergeContacts(cmd.Context(), args[1], args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contactsInfoAddCmd() *cobra.Command {
|
func contactsInfoAddCmd() *cobra.Command {
|
||||||
var infoType, value, category string
|
var infoType, value, category string
|
||||||
var isPrimary bool
|
var isPrimary bool
|
||||||
@@ -217,6 +241,111 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func personsFixNamesCmd() *cobra.Command {
|
||||||
|
var companyID int
|
||||||
|
var dryRun bool
|
||||||
|
var orgHint string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "fix-names",
|
||||||
|
Short: "Strip company annotations from last names; keep companyId link",
|
||||||
|
Long: `Repairs CRM persons whose lastName embeds a company ("Thomsen (Acme)")
|
||||||
|
or whose firstName is an email. Company belongs on companyId, not in the name.
|
||||||
|
|
||||||
|
With --company-id, only persons linked to that company are scanned.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := cmd.Context()
|
||||||
|
var list []map[string]any
|
||||||
|
if companyID > 0 {
|
||||||
|
list, err = c.ListCompanyPersons(ctx, strconv.Itoa(companyID))
|
||||||
|
} else {
|
||||||
|
all, err2 := c.ListAllContacts(ctx)
|
||||||
|
err = err2
|
||||||
|
for _, row := range all {
|
||||||
|
if isCo, _ := row["isCompany"].(bool); !isCo {
|
||||||
|
list = append(list, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fixed, skipped := 0, 0
|
||||||
|
for _, p := range list {
|
||||||
|
id := fmt.Sprint(p["id"])
|
||||||
|
// List payloads often omit contactInfos; refresh when name looks like email.
|
||||||
|
fn := strings.TrimSpace(fmt.Sprint(p["firstName"]))
|
||||||
|
if strings.Contains(fn, "@") || fn == "" {
|
||||||
|
if full, gerr := c.GetContact(ctx, id); gerr == nil && full != nil {
|
||||||
|
p = full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn = strings.TrimSpace(fmt.Sprint(p["firstName"]))
|
||||||
|
ln := strings.TrimSpace(fmt.Sprint(p["lastName"]))
|
||||||
|
dn := strings.TrimSpace(fmt.Sprint(p["displayName"]))
|
||||||
|
org := orgHint
|
||||||
|
if org == "" {
|
||||||
|
if co, ok := p["company"].(map[string]any); ok {
|
||||||
|
org = strings.TrimSpace(fmt.Sprint(co["displayName"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emails := contactEmailsFromMap(p)
|
||||||
|
for _, row := range onlyoffice.ContactInfoRows(p) {
|
||||||
|
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) == "email" {
|
||||||
|
if data := strings.TrimSpace(fmt.Sprint(row["data"])); data != "" {
|
||||||
|
emails = append(emails, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cf, cl := catalog.CleanPersonNames(fn, ln, dn, org, emails)
|
||||||
|
needName := cf != fn || cl != ln
|
||||||
|
linkID := companyID
|
||||||
|
if linkID == 0 {
|
||||||
|
if co, ok := p["company"].(map[string]any); ok {
|
||||||
|
linkID = int(flexIDFloat(co["id"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !needName {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := map[string]any{
|
||||||
|
"id": id, "from": fn + " / " + ln, "to": cf + " / " + cl, "companyId": linkID,
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
printObject(row)
|
||||||
|
fixed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.UpdatePerson(ctx, id, cf, cl, linkID, "", ""); err != nil {
|
||||||
|
return fmt.Errorf("update %s: %w", id, err)
|
||||||
|
}
|
||||||
|
printObject(row)
|
||||||
|
fixed++
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"fixed": fixed, "skipped": skipped, "dry_run": dryRun})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&companyID, "company-id", 0, "limit to persons of this company")
|
||||||
|
cmd.Flags().StringVar(&orgHint, "org", "", "org name hint for stripping suffixes")
|
||||||
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned renames only")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactEmailsFromMap(p map[string]any) []string {
|
||||||
|
var out []string
|
||||||
|
for _, k := range []string{"email", "primaryEmail"} {
|
||||||
|
if v := strings.TrimSpace(fmt.Sprint(p[k])); v != "" && v != "<nil>" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func companiesCreateCmd() *cobra.Command {
|
func companiesCreateCmd() *cobra.Command {
|
||||||
var name, email, website string
|
var name, email, website string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
// oo crm cleanup
|
// oo crm cleanup
|
||||||
// oo mails accounts | folders | list | get | delete
|
// oo mails accounts | folders | list | get | delete
|
||||||
// oo applications sync
|
// oo applications sync
|
||||||
|
// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply
|
||||||
//
|
//
|
||||||
// Every list supports `--output/-o json|table` (table is the default).
|
// Every list supports `--output/-o json|table` (table is the default).
|
||||||
//
|
//
|
||||||
|
|||||||
+357
-1
@@ -2,9 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/catalog"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ func init() {
|
|||||||
projectsCmd.AddCommand(prjCreateCmd())
|
projectsCmd.AddCommand(prjCreateCmd())
|
||||||
projectsCmd.AddCommand(prjUpdateCmd())
|
projectsCmd.AddCommand(prjUpdateCmd())
|
||||||
projectsCmd.AddCommand(prjDeleteCmd())
|
projectsCmd.AddCommand(prjDeleteCmd())
|
||||||
|
projectsCmd.AddCommand(prjContactsCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
func prjListCmd() *cobra.Command {
|
func prjListCmd() *cobra.Command {
|
||||||
@@ -116,17 +121,32 @@ func prjMilestonesCmd() *cobra.Command {
|
|||||||
|
|
||||||
func prjCreateCmd() *cobra.Command {
|
func prjCreateCmd() *cobra.Command {
|
||||||
var desc, resp string
|
var desc, resp string
|
||||||
|
var country, company string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create TITLE",
|
Use: "create TITLE",
|
||||||
Short: "Create a new project",
|
Short: "Create a new project",
|
||||||
|
Long: `Create a project. Prefer canonical titles:
|
||||||
|
|
||||||
|
CC | Company | Title
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
oo projects create "Mapbender" --country DE --company "Stadt Mainz"
|
||||||
|
oo projects create "DE | Acme | Geo Engineer"
|
||||||
|
|
||||||
|
When --country and --company are set, TITLE is only the third segment and the
|
||||||
|
full title is composed as "CC | Company | Title".`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
title := args[0]
|
||||||
|
if country != "" || company != "" {
|
||||||
|
title = catalog.FormatProjectTitle(country, company, args[0])
|
||||||
|
}
|
||||||
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
||||||
Title: args[0],
|
Title: title,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
ResponsibleID: resp,
|
ResponsibleID: resp,
|
||||||
})
|
})
|
||||||
@@ -142,6 +162,8 @@ func prjCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
||||||
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
|
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
|
||||||
|
cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)")
|
||||||
|
cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +182,25 @@ func prjUpdateCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("project id must be integer: %w", err)
|
return fmt.Errorf("project id must be integer: %w", err)
|
||||||
}
|
}
|
||||||
|
// OO update requires responsibleId; reuse current when not passed.
|
||||||
|
if resp == "" {
|
||||||
|
cur, gerr := c.GetProjectByID(cmd.Context(), args[0])
|
||||||
|
if gerr != nil {
|
||||||
|
return gerr
|
||||||
|
}
|
||||||
|
if r, ok := cur["responsible"].(map[string]any); ok {
|
||||||
|
resp = fmt.Sprint(r["id"])
|
||||||
|
}
|
||||||
|
if resp == "" || resp == "<nil>" {
|
||||||
|
resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"]))
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(fmt.Sprint(cur["title"]))
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = strings.TrimSpace(fmt.Sprint(cur["description"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
|
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -173,6 +214,11 @@ func prjUpdateCmd() *cobra.Command {
|
|||||||
"id": derefInt(p.ID),
|
"id": derefInt(p.ID),
|
||||||
"title": p.String(),
|
"title": p.String(),
|
||||||
})
|
})
|
||||||
|
// Confirm via GET — some OO builds return an empty body on PUT.
|
||||||
|
got, gerr := c.GetProjectByID(cmd.Context(), args[0])
|
||||||
|
if gerr == nil && got != nil {
|
||||||
|
printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -216,6 +262,316 @@ func derefString(p *string) string {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prjContactsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "contacts",
|
||||||
|
Short: "CRM contacts linked to a project (companies + persons)",
|
||||||
|
Long: `OnlyOffice Projects have two participant layers:
|
||||||
|
- Team (portal users) — ProjectTeam.aspx
|
||||||
|
- Contacts (CRM companies/persons) — linked via this API
|
||||||
|
|
||||||
|
Link employers/clients and people who worked on the engagement (e.g. git authors).`,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(prjContactsListCmd())
|
||||||
|
cmd.AddCommand(prjContactsAddCmd())
|
||||||
|
cmd.AddCommand(prjContactsRemoveCmd())
|
||||||
|
cmd.AddCommand(prjContactsLinkGitCmd())
|
||||||
|
cmd.AddCommand(prjContactsLinkAuthorsCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjContactsListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list PROJECT_ID",
|
||||||
|
Short: "List CRM contacts linked to the project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
list, err := c.ListProjectContacts(cmd.Context(), pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(list))
|
||||||
|
for _, row := range list {
|
||||||
|
rows = append(rows, map[string]any{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["displayName"],
|
||||||
|
"company": row["isCompany"],
|
||||||
|
"companyOf": row["companyName"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "name", "company", "companyOf"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjContactsAddCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "add PROJECT_ID CONTACT_ID [CONTACT_ID...]",
|
||||||
|
Short: "Link CRM contact(s) to the project",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
for _, raw := range args[1:] {
|
||||||
|
cid, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("contact id %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
if _, err := c.AddProjectContact(cmd.Context(), pid, cid); err != nil {
|
||||||
|
return fmt.Errorf("add %d: %w", cid, err)
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"project_id": pid, "contact_id": cid, "linked": true})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjContactsRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove PROJECT_ID CONTACT_ID [CONTACT_ID...]",
|
||||||
|
Short: "Unlink CRM contact(s) from the project",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
for _, raw := range args[1:] {
|
||||||
|
cid, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("contact id %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
if err := c.RemoveProjectContact(cmd.Context(), pid, cid); err != nil {
|
||||||
|
return fmt.Errorf("remove %d: %w", cid, err)
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"project_id": pid, "contact_id": cid, "unlinked": true})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjContactsLinkGitCmd() *cobra.Command {
|
||||||
|
var gitRoot string
|
||||||
|
var minCommits int
|
||||||
|
var dryRun bool
|
||||||
|
var companyIDs []int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "link-git PROJECT_ID",
|
||||||
|
Short: "Link CRM persons matched from git shortlog (+ optional companies)",
|
||||||
|
Long: `Runs git shortlog -sne --all in --git-root, matches author emails to CRM
|
||||||
|
persons (FindPersonByEmail), and links found contacts to the project.
|
||||||
|
Does not create new persons — approve/apply them via catalog first.
|
||||||
|
|
||||||
|
Also links --company-id contacts (e.g. Acme + end-client company).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if gitRoot == "" && len(companyIDs) == 0 {
|
||||||
|
return fmt.Errorf("need --git-root and/or --company-id")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
var authors []gitAuthor
|
||||||
|
if gitRoot != "" {
|
||||||
|
authors, err = gitShortlogAuthors(gitRoot, minCommits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&gitRoot, "git-root", "", "local git repository path")
|
||||||
|
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
|
||||||
|
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
|
||||||
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjContactsLinkAuthorsCmd() *cobra.Command {
|
||||||
|
var authorsFile string
|
||||||
|
var minCommits int
|
||||||
|
var dryRun bool
|
||||||
|
var companyIDs []int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "link-authors PROJECT_ID",
|
||||||
|
Short: "Link CRM persons from a git shortlog file (+ companies)",
|
||||||
|
Long: `Same as link-git, but reads authors from a file produced by:
|
||||||
|
|
||||||
|
git shortlog -sne --all > authors.txt
|
||||||
|
# or via ssh:
|
||||||
|
ssh ops-host 'git -C /path shortlog -sne --all' > authors.txt
|
||||||
|
|
||||||
|
Then: oo projects contacts link-authors 59 --from authors.txt --company-id 9`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if authorsFile == "" && len(companyIDs) == 0 {
|
||||||
|
return fmt.Errorf("need --from and/or --company-id")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
var authors []gitAuthor
|
||||||
|
if authorsFile != "" {
|
||||||
|
b, err := os.ReadFile(authorsFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authors = parseShortlog(string(b), minCommits)
|
||||||
|
}
|
||||||
|
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&authorsFile, "from", "", "path to git shortlog -sne output")
|
||||||
|
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
|
||||||
|
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
|
||||||
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkProjectPeople(cmd *cobra.Command, c *onlyoffice.Client, pid int, authors []gitAuthor, companyIDs []int, dryRun bool) error {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
existing, err := c.ListProjectContacts(ctx, pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
have := map[int]struct{}{}
|
||||||
|
for _, row := range existing {
|
||||||
|
have[int(flexIDFloat(row["id"]))] = struct{}{}
|
||||||
|
}
|
||||||
|
linked, skipped := 0, 0
|
||||||
|
for _, cid := range companyIDs {
|
||||||
|
if _, ok := have[cid]; ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
printObject(map[string]any{"would_link_company": cid})
|
||||||
|
linked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
|
||||||
|
return fmt.Errorf("company %d: %w", cid, err)
|
||||||
|
}
|
||||||
|
have[cid] = struct{}{}
|
||||||
|
linked++
|
||||||
|
printObject(map[string]any{"linked_company": cid})
|
||||||
|
}
|
||||||
|
for _, a := range authors {
|
||||||
|
p, err := c.FindPersonByEmail(ctx, a.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
printObject(map[string]any{
|
||||||
|
"skipped_email": a.Email, "name": a.Name, "commits": a.Commits, "reason": "no_crm_person",
|
||||||
|
})
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cid := int(flexIDFloat(p["id"]))
|
||||||
|
if _, ok := have[cid]; ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
printObject(map[string]any{"would_link_person": cid, "email": a.Email, "commits": a.Commits})
|
||||||
|
linked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
|
||||||
|
return fmt.Errorf("person %d (%s): %w", cid, a.Email, err)
|
||||||
|
}
|
||||||
|
have[cid] = struct{}{}
|
||||||
|
linked++
|
||||||
|
printObject(map[string]any{"linked_person": cid, "email": a.Email, "commits": a.Commits})
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"project_id": pid, "linked": linked, "skipped": skipped, "dry_run": dryRun})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseShortlog(text string, minCommits int) []gitAuthor {
|
||||||
|
var authors []gitAuthor
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if n < minCommits {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := parts[1]
|
||||||
|
lt := strings.LastIndex(rest, "<")
|
||||||
|
gt := strings.LastIndex(rest, ">")
|
||||||
|
if lt < 0 || gt <= lt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.ToLower(strings.TrimSpace(rest[lt+1 : gt]))
|
||||||
|
name := strings.TrimSpace(rest[:lt])
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[email]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[email] = struct{}{}
|
||||||
|
authors = append(authors, gitAuthor{Commits: n, Name: name, Email: email})
|
||||||
|
}
|
||||||
|
return authors
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitAuthor struct {
|
||||||
|
Commits int
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitShortlogAuthors(root string, minCommits int) ([]gitAuthor, error) {
|
||||||
|
cmd := exec.Command("git", "-C", root, "shortlog", "-sne", "--all")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git shortlog in %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return parseShortlog(string(out), minCommits), nil
|
||||||
|
}
|
||||||
|
|
||||||
func derefInt(p *int) int {
|
func derefInt(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindCompany searches for a company contact with an exact (case-insensitive)
|
// FindCompany searches for a company contact with an exact (case-insensitive)
|
||||||
// displayName match. Returns nil when not found.
|
// displayName match after slogan/legal-suffix normalization. Returns nil when not found.
|
||||||
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
||||||
needle := CompanyGroupingKey(name)
|
needle := CompanyGroupingKey(name)
|
||||||
|
if needle == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
const page = 50
|
const page = 50
|
||||||
for start := 0; ; start += page {
|
for start := 0; ; start += page {
|
||||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
items, total, err := c.ListContacts(ctx, page, start, name)
|
||||||
@@ -67,6 +70,19 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Search filter may miss longer legal names; fall back to a full company scan.
|
||||||
|
all, err := c.ListAllContacts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, co := range all {
|
||||||
|
if !isCompany(co) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
|
||||||
|
return co, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +112,52 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindPersonByEmail finds a person whose primary or contact-info email matches
|
||||||
|
// (case-insensitive). Companies are skipped. Returns nil when not found.
|
||||||
|
func (c *Client) FindPersonByEmail(ctx context.Context, email string) (map[string]any, error) {
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if needle == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
all, err := c.ListAllContacts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range all {
|
||||||
|
if isCompany(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, em := range contactEmailValues(p) {
|
||||||
|
if strings.ToLower(strings.TrimSpace(em)) == needle {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactEmailValues(p map[string]any) []string {
|
||||||
|
var out []string
|
||||||
|
for _, key := range []string{"email", "primaryEmail"} {
|
||||||
|
if v := strings.TrimSpace(fmt.Sprint(p[key])); v != "" && v != "<nil>" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range ContactInfoRows(p) {
|
||||||
|
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||||
|
if data == "" || data == "<nil>" {
|
||||||
|
data = strings.TrimSpace(fmt.Sprint(row["value"]))
|
||||||
|
}
|
||||||
|
if data != "" {
|
||||||
|
out = append(out, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func isCompany(m map[string]any) bool {
|
func isCompany(m map[string]any) bool {
|
||||||
v, ok := m["isCompany"].(bool)
|
v, ok := m["isCompany"].(bool)
|
||||||
return ok && v
|
return ok && v
|
||||||
@@ -125,6 +187,24 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
|
|||||||
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePerson updates first/last name and optional company link on a person.
|
||||||
|
// companyID == 0 leaves the company association unchanged.
|
||||||
|
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("firstName", first)
|
||||||
|
fields.Set("lastName", last)
|
||||||
|
if companyID != 0 {
|
||||||
|
fields.Set("companyId", strconv.Itoa(companyID))
|
||||||
|
}
|
||||||
|
if jobTitle != "" {
|
||||||
|
fields.Set("jobTitle", jobTitle)
|
||||||
|
}
|
||||||
|
if about != "" {
|
||||||
|
fields.Set("about", about)
|
||||||
|
}
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
||||||
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
||||||
if category == "" {
|
if category == "" {
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cid := flexInt(row["companyId"])
|
cid := flexInt(row["companyId"])
|
||||||
|
if cid == 0 {
|
||||||
|
if co, ok := row["company"].(map[string]any); ok {
|
||||||
|
cid = flexInt(co["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
if cid == 0 {
|
if cid == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-3
@@ -10,6 +10,9 @@ var multiSpace = regexp.MustCompile(`\s+`)
|
|||||||
// sloganSeparators split a company name from a trailing tagline/slogan.
|
// sloganSeparators split a company name from a trailing tagline/slogan.
|
||||||
var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"}
|
var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"}
|
||||||
|
|
||||||
|
// companyLegalSuffix matches trailing legal-form tokens (GmbH, & Co. KG, …).
|
||||||
|
var companyLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(&\s*co\.?\s*kg\.?|co\.?\s*kg\.?|gmbh|ag|ltd\.?|llc\.?|inc\.?|plc\.?|s\.?l\.?|sarl|b\.?v\.?)\s*$`)
|
||||||
|
|
||||||
// StripSloganSuffix returns the part before an em/en dash tagline, e.g.
|
// StripSloganSuffix returns the part before an em/en dash tagline, e.g.
|
||||||
// "Affirm — Fraud Engineering" → "Affirm".
|
// "Affirm — Fraud Engineering" → "Affirm".
|
||||||
func StripSloganSuffix(s string) string {
|
func StripSloganSuffix(s string) string {
|
||||||
@@ -22,9 +25,23 @@ func StripSloganSuffix(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans).
|
// StripCompanyLegalSuffix removes trailing GmbH / & Co. KG / Ltd / … tokens.
|
||||||
|
func StripCompanyLegalSuffix(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimRight(s, ",; ")
|
||||||
|
for {
|
||||||
|
next := companyLegalSuffix.ReplaceAllString(s, "")
|
||||||
|
next = strings.TrimRight(strings.TrimSpace(next), ",; ")
|
||||||
|
if next == s || next == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans and legal suffixes).
|
||||||
func CompanyGroupingKey(s string) string {
|
func CompanyGroupingKey(s string) string {
|
||||||
return NormalizeCompanyName(StripSloganSuffix(s))
|
return NormalizeCompanyName(StripCompanyLegalSuffix(StripSloganSuffix(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
|
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
|
||||||
@@ -75,8 +92,53 @@ func FixDealTitle(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ContactInfoKey groups contact info rows by type and normalized value.
|
// ContactInfoKey groups contact info rows by type and normalized value.
|
||||||
|
// ContactInfoKey builds a dedupe key for a contact-info row.
|
||||||
func ContactInfoKey(infoType, value string) string {
|
func ContactInfoKey(infoType, value string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
|
return NormalizeContactInfoType(infoType) + "|" + strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeContactInfoType maps OO numeric infoType codes and string labels to
|
||||||
|
// a canonical lowercase label (email, phone, …).
|
||||||
|
func NormalizeContactInfoType(infoType string) string {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(infoType))
|
||||||
|
switch t {
|
||||||
|
case "1", "email":
|
||||||
|
return "email"
|
||||||
|
case "2", "phone":
|
||||||
|
return "phone"
|
||||||
|
case "3", "website":
|
||||||
|
return "website"
|
||||||
|
case "4", "skype":
|
||||||
|
return "skype"
|
||||||
|
case "5", "twitter":
|
||||||
|
return "twitter"
|
||||||
|
case "6", "linkedin":
|
||||||
|
return "linkedin"
|
||||||
|
case "7", "facebook":
|
||||||
|
return "facebook"
|
||||||
|
case "8", "address":
|
||||||
|
return "address"
|
||||||
|
case "9", "livejournal":
|
||||||
|
return "livejournal"
|
||||||
|
case "10", "myspace":
|
||||||
|
return "myspace"
|
||||||
|
case "11", "gmail":
|
||||||
|
return "gmail"
|
||||||
|
case "12", "blogger":
|
||||||
|
return "blogger"
|
||||||
|
case "13", "yahoo":
|
||||||
|
return "yahoo"
|
||||||
|
case "14", "msn":
|
||||||
|
return "msn"
|
||||||
|
case "15", "icq":
|
||||||
|
return "icq"
|
||||||
|
case "16", "jabber":
|
||||||
|
return "jabber"
|
||||||
|
case "17", "aim":
|
||||||
|
return "aim"
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
// MemberDisplayKey normalizes a member displayName for duplicate detection.
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func TestCompanyGroupingKey(t *testing.T) {
|
|||||||
if a != b {
|
if a != b {
|
||||||
t.Fatalf("%q != %q", a, b)
|
t.Fatalf("%q != %q", a, b)
|
||||||
}
|
}
|
||||||
|
if CompanyGroupingKey("Acme") != CompanyGroupingKey("Acme GmbH & Co. KG,") {
|
||||||
|
t.Fatalf("legal suffix should not change grouping key: %q vs %q",
|
||||||
|
CompanyGroupingKey("Acme"), CompanyGroupingKey("Acme GmbH & Co. KG,"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpportunityTitlesMatchSlogan(t *testing.T) {
|
func TestOpportunityTitlesMatchSlogan(t *testing.T) {
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ require (
|
|||||||
github.com/charmbracelet/glamour v0.8.0
|
github.com/charmbracelet/glamour v0.8.0
|
||||||
github.com/charmbracelet/lipgloss v0.12.1
|
github.com/charmbracelet/lipgloss v0.12.1
|
||||||
github.com/charmbracelet/x/ansi v0.1.4
|
github.com/charmbracelet/x/ansi v0.1.4
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
|
||||||
github.com/eslider/go-xls/v2 v2.1.0
|
github.com/eslider/go-xls/v2 v2.1.0
|
||||||
github.com/google/go-querystring v1.2.0
|
github.com/google/go-querystring v1.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -25,6 +28,8 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
@@ -34,13 +39,18 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
golang.org/x/term v0.43.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
modernc.org/libc v1.74.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,14 +33,24 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:Yyn
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
|
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
|
||||||
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
|
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
@@ -66,8 +76,12 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
@@ -87,15 +101,50 @@ github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc
|
|||||||
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||||
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
|
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||||
|
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListProjectContacts returns CRM persons/companies linked to a project.
|
||||||
|
// GET /api/2.0/crm/contact/project/{projectid}
|
||||||
|
func (c *Client) ListProjectContacts(ctx context.Context, projectID int) ([]map[string]any, error) {
|
||||||
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/project/%d", projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProjectContact links a CRM contact (person or company) to a project.
|
||||||
|
// POST /api/2.0/project/{projectid}/contact form: contactId=
|
||||||
|
func (c *Client) AddProjectContact(ctx context.Context, projectID, contactID int) (map[string]any, error) {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("contactId", strconv.Itoa(contactID))
|
||||||
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%d/contact", projectID), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProjectContact unlinks a CRM contact from a project.
|
||||||
|
// DELETE /api/2.0/project/{projectid}/contact?contactId=
|
||||||
|
func (c *Client) RemoveProjectContact(ctx context.Context, projectID, contactID int) error {
|
||||||
|
_, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/project/%d/contact?contactId=%d", projectID, contactID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectTeam returns portal users on the project team.
|
||||||
|
// GET /api/2.0/project/{projectid}/team
|
||||||
|
func (c *Client) GetProjectTeam(ctx context.Context, projectID int) ([]map[string]any, error) {
|
||||||
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/project/%d/team", projectID))
|
||||||
|
}
|
||||||
+16
-6
@@ -193,16 +193,26 @@ func (c *Client) DeleteProject(id int) (*Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProject updates project fields.
|
// UpdateProject updates project fields.
|
||||||
|
// OnlyOffice requires responsibleId on PUT; callers should set ResponsibleID
|
||||||
|
// (the CLI fills it from the current project when omitted).
|
||||||
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
|
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
|
||||||
p := &Project{}
|
p := new(Project)
|
||||||
return p, c.Query(Request{
|
body := map[string]any{
|
||||||
|
"title": req.Title,
|
||||||
|
"description": req.Description,
|
||||||
|
"responsibleId": req.ResponsibleID,
|
||||||
|
}
|
||||||
|
err := c.Query(Request{
|
||||||
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
|
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
|
||||||
Method: "PUT",
|
Method: "PUT",
|
||||||
Body: req,
|
Body: body,
|
||||||
},
|
}, &struct {
|
||||||
&struct {
|
|
||||||
Response *Project `json:"response"`
|
Response *Project `json:"response"`
|
||||||
}{p})
|
}{Response: p})
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
|
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
|
||||||
|
|||||||
Reference in New Issue
Block a user