Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1624920e | ||
|
|
1bf417d70f | ||
|
|
aa79a83c0a | ||
|
|
179f30644d | ||
|
|
b334f9ff86 | ||
|
|
e07cf743f9 | ||
|
|
8b764a36a1 | ||
|
|
aa4e71b9d2 | ||
|
|
1a93579635 | ||
|
|
76067f65d0 | ||
|
|
3b55de5b1d | ||
|
|
715ed23733 | ||
|
|
c7275b5c98 | ||
|
|
b9cea62d46 |
@@ -9,6 +9,7 @@ permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
@@ -23,6 +24,18 @@ jobs:
|
||||
manifest-file: .release-please-manifest.json
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tags/releases created with GITHUB_TOKEN do not trigger other workflows
|
||||
# (push:tags on Release.yml never fires). Dispatch GoReleaser explicitly.
|
||||
- name: Trigger GoReleaser
|
||||
if: ${{ steps.release.outputs.release_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.release.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dispatching Release workflow for $TAG"
|
||||
gh workflow run release.yml --repo "${{ github.repository }}" -f "tag=${TAG}"
|
||||
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
|
||||
@@ -18,10 +18,28 @@ jobs:
|
||||
name: GoReleaser
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Always clone default branch first. workflow_dispatch often races with
|
||||
# release-please creating the tag; fetching refs/tags/X before it exists
|
||||
# fails checkout. Retry fetch, then check out the tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
|
||||
- name: Checkout release tag
|
||||
env:
|
||||
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Releasing tag: $TAG"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
|
||||
break
|
||||
fi
|
||||
echo "tag $TAG not visible yet (attempt $i); sleeping…"
|
||||
sleep 10
|
||||
done
|
||||
git rev-parse "refs/tags/${TAG}"
|
||||
git checkout --force "refs/tags/${TAG}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.8.0"
|
||||
".": "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):
|
||||
- `main.go` — entry point (docstring lists the command tree).
|
||||
- `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.
|
||||
- **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/`.
|
||||
- **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.
|
||||
|
||||
@@ -4,6 +4,47 @@ 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
|
||||
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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** dispatch GoReleaser after release-please; allow Tests dispatch ([#13](https://github.com/eSlider/go-onlyoffice/issues/13)) ([bf2fa63](https://github.com/eSlider/go-onlyoffice/commit/bf2fa637aaf0419ba5af40b25b64f9ebe24116e8))
|
||||
|
||||
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
|
||||
|
||||
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
|
||||
|
||||
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
|
||||
|
||||
|
||||
|
||||
@@ -635,6 +635,7 @@ oo tasks files detach 208 12345
|
||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||
| `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
|
||||
CLI-only concern — the library itself never loads dotfiles).
|
||||
@@ -733,6 +734,27 @@ oo contacts get 857 -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`)
|
||||
|
||||
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
|
||||
|
||||
@@ -171,10 +171,15 @@ func CellText(it Item, key string) string {
|
||||
if it.Raw == nil {
|
||||
return ""
|
||||
}
|
||||
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
|
||||
// CRM tasks use camelCase deadLine; project tasks use deadline.
|
||||
rawDL := it.Raw["deadline"]
|
||||
if rawDL == nil || formatAny(rawDL) == "" {
|
||||
rawDL = it.Raw["deadLine"]
|
||||
}
|
||||
if rel := FormatRelativeDeadline(rawDL); rel != "" {
|
||||
return rel
|
||||
}
|
||||
return formatAny(it.Raw["deadline"])
|
||||
return formatAny(rawDL)
|
||||
case "responsible":
|
||||
if it.Kind == KindTask || it.Kind == KindCRMTask {
|
||||
return TaskResponsibleLabel(it.Raw)
|
||||
|
||||
@@ -169,6 +169,9 @@ func personName(v any) string {
|
||||
return ""
|
||||
}
|
||||
if name := strRaw(m, "displayName"); name != "" {
|
||||
if strings.EqualFold(name, "Profile has been removed") {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
first := strRaw(m, "firstName")
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
|
||||
want := []string{
|
||||
"calendar", "projects", "tasks", "users", "whoami",
|
||||
"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()))
|
||||
for _, c := range rootCmd.Commands() {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ var outputFormat = "table"
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "oo",
|
||||
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,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/catalog"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -39,12 +41,14 @@ func init() {
|
||||
contactsCmd.AddCommand(contactsListCmd(nil))
|
||||
contactsCmd.AddCommand(contactsGetCmd())
|
||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||
contactsCmd.AddCommand(contactsMergeCmd())
|
||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||
|
||||
only := true
|
||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||
personsCmd.AddCommand(personsCreateCmd())
|
||||
personsCmd.AddCommand(personsFixNamesCmd())
|
||||
personsCmd.AddCommand(contactsDeleteCmd())
|
||||
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 {
|
||||
var infoType, value, category string
|
||||
var isPrimary bool
|
||||
@@ -217,6 +241,111 @@ func personsCreateCmd() *cobra.Command {
|
||||
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 {
|
||||
var name, email, website string
|
||||
cmd := &cobra.Command{
|
||||
|
||||
+103
-3
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
rootCmd.AddCommand(crmTasksCmd)
|
||||
crmTasksCmd.AddCommand(crmTasksListCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
||||
}
|
||||
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
|
||||
if outputFormat == "table" {
|
||||
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||
}
|
||||
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
|
||||
printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create TITLE",
|
||||
Aliases: []string{"add"},
|
||||
Short: "Create a CRM task",
|
||||
Short: "Create a CRM task (assigns self + deadline default now+14d)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d")
|
||||
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
|
||||
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
||||
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
||||
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func crmTaskNeedsOwner(t map[string]any) (need bool, name string) {
|
||||
resp, _ := t["responsible"].(map[string]any)
|
||||
rid, _ := resp["id"].(string)
|
||||
name, _ = resp["displayName"].(string)
|
||||
need = rid == "" || strings.EqualFold(name, "Profile has been removed")
|
||||
return need, name
|
||||
}
|
||||
|
||||
func crmTaskCategoryID(t map[string]any) int {
|
||||
cat, ok := t["category"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch v := cat["id"].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func crmTasksReassignSelfCmd() *cobra.Command {
|
||||
var apply bool
|
||||
var count, max int
|
||||
cmd := &cobra.Command{
|
||||
Use: "reassign-self",
|
||||
Short: "Reassign CRM tasks with missing/removed owner to the authenticated user",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := c.SelfUserID(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type item struct {
|
||||
id, title, deadline, ownerName string
|
||||
categoryID int
|
||||
}
|
||||
var todos []item
|
||||
scanned := 0
|
||||
for start := 0; ; start += count {
|
||||
list, total, err := c.ListCRMTasks(cmd.Context(), count, start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range list {
|
||||
scanned++
|
||||
need, name := crmTaskNeedsOwner(t)
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
title, _ := t["title"].(string)
|
||||
dl, _ := t["deadLine"].(string)
|
||||
todos = append(todos, item{
|
||||
id: fmt.Sprint(t["id"]), title: title, deadline: dl,
|
||||
ownerName: name, categoryID: crmTaskCategoryID(t),
|
||||
})
|
||||
if max > 0 && len(todos) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fixed := 0
|
||||
for _, t := range todos {
|
||||
fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid)
|
||||
if !apply {
|
||||
fixed++
|
||||
continue
|
||||
}
|
||||
if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil {
|
||||
return fmt.Errorf("update %s: %w", t.id, err)
|
||||
}
|
||||
fixed++
|
||||
}
|
||||
|
||||
mode := "DRY-RUN"
|
||||
if apply {
|
||||
mode = "APPLIED"
|
||||
}
|
||||
fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)")
|
||||
cmd.Flags().IntVar(&count, "count", 100, "page size")
|
||||
cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func crmTasksDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete TASK_ID [TASK_ID...]",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// oo crm cleanup
|
||||
// oo mails accounts | folders | list | get | delete
|
||||
// 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).
|
||||
//
|
||||
|
||||
+357
-1
@@ -2,9 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/catalog"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,6 +26,7 @@ func init() {
|
||||
projectsCmd.AddCommand(prjCreateCmd())
|
||||
projectsCmd.AddCommand(prjUpdateCmd())
|
||||
projectsCmd.AddCommand(prjDeleteCmd())
|
||||
projectsCmd.AddCommand(prjContactsCmd())
|
||||
}
|
||||
|
||||
func prjListCmd() *cobra.Command {
|
||||
@@ -116,17 +121,32 @@ func prjMilestonesCmd() *cobra.Command {
|
||||
|
||||
func prjCreateCmd() *cobra.Command {
|
||||
var desc, resp string
|
||||
var country, company string
|
||||
cmd := &cobra.Command{
|
||||
Use: "create TITLE",
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := args[0]
|
||||
if country != "" || company != "" {
|
||||
title = catalog.FormatProjectTitle(country, company, args[0])
|
||||
}
|
||||
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
||||
Title: args[0],
|
||||
Title: title,
|
||||
Description: desc,
|
||||
ResponsibleID: resp,
|
||||
})
|
||||
@@ -142,6 +162,8 @@ func prjCreateCmd() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -160,6 +182,25 @@ func prjUpdateCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
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{
|
||||
ID: id,
|
||||
Title: title,
|
||||
@@ -173,6 +214,11 @@ func prjUpdateCmd() *cobra.Command {
|
||||
"id": derefInt(p.ID),
|
||||
"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
|
||||
},
|
||||
}
|
||||
@@ -216,6 +262,316 @@ func derefString(p *string) string {
|
||||
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 {
|
||||
if p == nil {
|
||||
return 0
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ func taskCreateCmd() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d; always assigned to you)")
|
||||
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListContacts returns a page of CRM contacts and the total count.
|
||||
@@ -45,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)
|
||||
// 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) {
|
||||
needle := CompanyGroupingKey(name)
|
||||
if needle == "" {
|
||||
return nil, nil
|
||||
}
|
||||
const page = 50
|
||||
for start := 0; ; start += page {
|
||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
||||
@@ -66,6 +70,19 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,6 +112,52 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
|
||||
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 {
|
||||
v, ok := m["isCompany"].(bool)
|
||||
return ok && v
|
||||
@@ -124,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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
||||
if category == "" {
|
||||
@@ -491,11 +572,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map
|
||||
}
|
||||
|
||||
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
||||
// Empty deadline defaults to now+14d (API requires a deadline). Empty
|
||||
// responsibleID falls back to the authenticated user — without this the
|
||||
// portal assigns a stub "Profile has been removed" owner.
|
||||
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
||||
if deadline == "" {
|
||||
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
}
|
||||
if categoryID == 0 {
|
||||
categoryID = 2 // Opportunity — matches applications sync
|
||||
}
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("deadline", deadline)
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
fields.Set("responsibleId", uid)
|
||||
if contactID != 0 {
|
||||
fields.Set("contactId", strconv.Itoa(contactID))
|
||||
}
|
||||
@@ -511,6 +606,29 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
||||
}
|
||||
|
||||
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
|
||||
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
|
||||
// by the API only if omitted — callers should pass deadLine when known.
|
||||
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
|
||||
if responsibleID == "" {
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
|
||||
}
|
||||
responsibleID = uid
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("responsibleId", responsibleID)
|
||||
if deadline != "" {
|
||||
fields.Set("deadline", deadline)
|
||||
}
|
||||
if categoryID != 0 {
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
}
|
||||
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
|
||||
}
|
||||
|
||||
// DeleteCRMTask removes a CRM task by id.
|
||||
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||
|
||||
@@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st
|
||||
continue
|
||||
}
|
||||
cid := flexInt(row["companyId"])
|
||||
if cid == 0 {
|
||||
if co, ok := row["company"].(map[string]any); ok {
|
||||
cid = flexInt(co["id"])
|
||||
}
|
||||
}
|
||||
if cid == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
+65
-3
@@ -10,6 +10,9 @@ var multiSpace = regexp.MustCompile(`\s+`)
|
||||
// sloganSeparators split a company name from a trailing tagline/slogan.
|
||||
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.
|
||||
// "Affirm — Fraud Engineering" → "Affirm".
|
||||
func StripSloganSuffix(s string) string {
|
||||
@@ -22,9 +25,23 @@ func StripSloganSuffix(s string) string {
|
||||
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 {
|
||||
return NormalizeCompanyName(StripSloganSuffix(s))
|
||||
return NormalizeCompanyName(StripCompanyLegalSuffix(StripSloganSuffix(s)))
|
||||
}
|
||||
|
||||
// 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 builds a dedupe key for a contact-info row.
|
||||
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.
|
||||
|
||||
@@ -65,6 +65,10 @@ func TestCompanyGroupingKey(t *testing.T) {
|
||||
if 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) {
|
||||
|
||||
@@ -9,12 +9,15 @@ require (
|
||||
github.com/charmbracelet/glamour v0.8.0
|
||||
github.com/charmbracelet/lipgloss v0.12.1
|
||||
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/google/go-querystring v1.2.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -25,6 +28,8 @@ require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // 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/inconshreveable/mousetrap v1.1.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/cancelreader v0.2.2 // 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/spf13/pflag v1.0.9 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.3 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.43.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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
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/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/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/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/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/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
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/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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
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/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
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/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
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/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
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/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.
|
||||
// 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) {
|
||||
p := &Project{}
|
||||
return p, c.Query(Request{
|
||||
p := new(Project)
|
||||
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),
|
||||
Method: "PUT",
|
||||
Body: req,
|
||||
},
|
||||
&struct {
|
||||
Body: body,
|
||||
}, &struct {
|
||||
Response *Project `json:"response"`
|
||||
}{p})
|
||||
}{Response: p})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
|
||||
|
||||
@@ -233,17 +233,26 @@ func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any
|
||||
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
|
||||
// Prefer the typed CreateProjectTask for new code; AddTask is kept for
|
||||
// parity with the Python reference tooling.
|
||||
//
|
||||
// Always assigns the authenticated user as responsible (form field
|
||||
// "responsibles"). Empty deadline defaults to today+14 days.
|
||||
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
|
||||
if projectID == "" {
|
||||
projectID = c.defaults.ProjectID
|
||||
}
|
||||
if deadline == "" {
|
||||
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AddTask: resolve self: %w", err)
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("description", description)
|
||||
fields.Set("priority", strconv.Itoa(priority))
|
||||
if deadline != "" {
|
||||
fields.Set("deadline", deadline)
|
||||
}
|
||||
fields.Set("responsibles", uid)
|
||||
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user