Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ced804a6f | ||
|
|
256864f613 | ||
|
|
a3a6ad3543 | ||
|
|
0161a92c52 | ||
|
|
0e80a1a058 | ||
|
|
8b27539145 | ||
|
|
a0add3b771 | ||
|
|
b58940fcf2 | ||
|
|
11aa23f1a1 | ||
|
|
42c31d4b48 | ||
|
|
f95830961d | ||
|
|
6b5a82bef8 | ||
|
|
490426c277 | ||
|
|
31c556d34c | ||
|
|
7f35b0e910 | ||
|
|
73b1050a83 | ||
|
|
d81e7de911 | ||
|
|
3201a58df7 | ||
|
|
19a7e9cbf8 | ||
|
|
2e1624920e | ||
|
|
1bf417d70f | ||
|
|
aa79a83c0a | ||
|
|
179f30644d | ||
|
|
b334f9ff86 | ||
|
|
e07cf743f9 | ||
|
|
8b764a36a1 | ||
|
|
aa4e71b9d2 |
+1
-1
@@ -11,7 +11,7 @@ ONLYOFFICE_PASS=
|
|||||||
# ONLYOFFICE_NAME=
|
# ONLYOFFICE_NAME=
|
||||||
# ONLYOFFICE_PASSWORD=
|
# ONLYOFFICE_PASSWORD=
|
||||||
#
|
#
|
||||||
# produktor.io operator aliases (CLI-only):
|
# Optional CLI-only aliases:
|
||||||
# OO_URL=
|
# OO_URL=
|
||||||
# OO_USER=
|
# OO_USER=
|
||||||
# OO_PASS=
|
# OO_PASS=
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.8.3"
|
".": "0.11.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
||||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
|
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
|
||||||
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
||||||
- **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`, `mails.go`, `invoices.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.
|
||||||
- **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.
|
||||||
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
|
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in private [`eSlider/oo-workspace`](https://git.produktor.io/eSlider/oo-workspace) (`oow`), not in this public tree.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,70 @@ 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).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
||||||
|
* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
|
||||||
|
* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
|
||||||
|
|
||||||
|
## [0.11.0](https://github.com/eSlider/go-onlyoffice/compare/v0.10.0...v0.11.0) (2026-08-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **catalog:** port oo catalog (scan/merge/match/apply) from legacy branch ([256864f](https://github.com/eSlider/go-onlyoffice/commit/256864f613f31570cf995dddc51255a9a91a38f8))
|
||||||
|
|
||||||
|
## [0.10.0](https://github.com/eSlider/go-onlyoffice/compare/v0.9.0...v0.10.0) (2026-08-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **crm:** add invoices CLI and opportunity update ([19a7e9c](https://github.com/eSlider/go-onlyoffice/commit/19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41))
|
||||||
|
* **crm:** allow updating invoice terms via oo ([31c556d](https://github.com/eSlider/go-onlyoffice/commit/31c556d34cedd65d6957441f448520af9a7e380b))
|
||||||
|
* **crm:** invoice update notes and PO fields ([d81e7de](https://github.com/eSlider/go-onlyoffice/commit/d81e7de9114a29439142efba67f205653f562730))
|
||||||
|
* **crm:** invoices CLI and opportunity update ([f958309](https://github.com/eSlider/go-onlyoffice/commit/f95830961d3aecba65f3cb074d33c9c98f6c6cf2))
|
||||||
|
* **mail:** draft, attach, and draft-invoice CLI ([73b1050](https://github.com/eSlider/go-onlyoffice/commit/73b1050a83e9e49dcf99d8a966a84746376d7dd9))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **crm:** JSON person update and oo persons update CLI ([42c31d4](https://github.com/eSlider/go-onlyoffice/commit/42c31d4b485bfab6903aeed0dd6fb2c43f61b4f2))
|
||||||
|
* **crm:** link invoices to opportunities ([3201a58](https://github.com/eSlider/go-onlyoffice/commit/3201a58df75bceb7d41034970cf384a9169163ed))
|
||||||
|
* **mail:** German spacing for invoice draft template ([7f35b0e](https://github.com/eSlider/go-onlyoffice/commit/7f35b0e91075d871f87de95b6c84b2353e02f9cf))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* **crm:** capture association graph and invoice/mail quirks ([490426c](https://github.com/eSlider/go-onlyoffice/commit/490426c2779160f1a9b168f6ccae9dd8dccf89e9))
|
||||||
|
* **crm:** clarify mail API send vs signature for chat links ([6b5a82b](https://github.com/eSlider/go-onlyoffice/commit/6b5a82bef831341aee16b0b0ea1fe6db2c24567d))
|
||||||
|
* **crm:** Team vs Contacts and persons update note ([11aa23f](https://github.com/eSlider/go-onlyoffice/commit/11aa23f1a17471bad2bd95fddb769a94afbcf97a))
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Example Author
|
Copyright (c) 2026 Andriy Oblivantsev
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ deals, total, _ := client.ListOpportunities(ctx, 50, 0)
|
|||||||
company, _ := client.FindCompany(ctx, "ACME")
|
company, _ := client.FindCompany(ctx, "ACME")
|
||||||
|
|
||||||
// Subtasks (form-encoded)
|
// Subtasks (form-encoded)
|
||||||
client.AddSubtask(ctx, "4242", "Prepare CV")
|
client.AddSubtask(ctx, "4242", "Prepare notes")
|
||||||
```
|
```
|
||||||
|
|
||||||
### oo (bundled CLI)
|
### oo (bundled CLI)
|
||||||
@@ -552,14 +552,13 @@ oo calendar events --start 2026-04-24 --end 2026-05-01
|
|||||||
oo projects list
|
oo projects list
|
||||||
oo projects get 33
|
oo projects get 33
|
||||||
oo tasks list --all --verbose
|
oo tasks list --all --verbose
|
||||||
oo tasks subtask add 4242 "Prepare CV"
|
oo tasks subtask add 4242 "Prepare notes"
|
||||||
oo persons create --first Jane --last Doe --email jane@example.com
|
oo persons create --first Jane --last Doe --email jane@example.com
|
||||||
oo companies create --name "Acme GmbH" --website https://acme.com
|
oo companies create --name "Acme GmbH" --website https://acme.com
|
||||||
oo opportunities list
|
oo opportunities list
|
||||||
oo opportunities stages
|
oo opportunities stages
|
||||||
oo cases list
|
oo cases list
|
||||||
oo crm-tasks categories
|
oo crm-tasks categories
|
||||||
oo applications sync --path ./applications/2026 --apply
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### office (TUI)
|
### office (TUI)
|
||||||
@@ -613,7 +612,7 @@ oo projects files download 12345 --to ./copy.md
|
|||||||
oo projects files rename 12345 notes-v2.md
|
oo projects files rename 12345 notes-v2.md
|
||||||
oo projects files delete 12345
|
oo projects files delete 12345
|
||||||
oo tasks files list 208
|
oo tasks files list 208
|
||||||
oo tasks files upload 208 ./cv.pdf
|
oo tasks files upload 208 ./notes.pdf
|
||||||
oo tasks files detach 208 12345
|
oo tasks files detach 208 12345
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -623,33 +622,28 @@ oo tasks files detach 208 12345
|
|||||||
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
||||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
|
||||||
| `persons` | `list` (filtered), `create`, `delete` |
|
|
||||||
| `companies` | `list` (filtered), `create`, `delete` |
|
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
|
|
||||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||||
|
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||||
| `crm` | `cleanup` |
|
| `crm` | `cleanup` |
|
||||||
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
|
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
|
||||||
| `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` |
|
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
Canonical `ONLYOFFICE_*` variables win over aliases. For produktor.io operator
|
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
|
||||||
files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for
|
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
||||||
`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
|
||||||
|
|
||||||
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
||||||
|
|
||||||
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||||
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
|
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, …) were
|
||||||
> `oo applications-sync`, …) were replaced by subject-based equivalents
|
> replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`,
|
||||||
> (`oo calendar events`, `oo tasks list`, `oo contacts list`,
|
> `oo contacts list`). Flags on leaf commands are unchanged.
|
||||||
> `oo applications sync`). Flags on leaf commands are unchanged.
|
|
||||||
|
|
||||||
## oo CLI use cases
|
## oo CLI use cases
|
||||||
|
|
||||||
@@ -671,7 +665,7 @@ Every list command accepts `-o table` (default) or `-o json` for scripting.
|
|||||||
### CRM cleanup after imports or sync drift
|
### CRM cleanup after imports or sync drift
|
||||||
|
|
||||||
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
|
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
|
||||||
the same email on a contact three times, deals titled ` @ contoso`, or the same
|
the same email on a contact three times, deals titled ` @ Acme`, or the same
|
||||||
HR contact linked to a deal twice.
|
HR contact linked to a deal twice.
|
||||||
|
|
||||||
**One-shot fix** — runs every dedupe pass in order:
|
**One-shot fix** — runs every dedupe pass in order:
|
||||||
@@ -710,13 +704,39 @@ oo contacts dedupe-info
|
|||||||
# Two deals with the same title
|
# Two deals with the same title
|
||||||
oo opportunities dedupe
|
oo opportunities dedupe
|
||||||
|
|
||||||
# Same contact attached twice to one deal (common after applications sync)
|
# Same contact attached twice to one deal
|
||||||
oo opportunities dedupe-members
|
oo opportunities dedupe-members
|
||||||
|
|
||||||
# Titles like " @ contoso" or extra whitespace
|
# Titles like " @ Acme" or extra whitespace
|
||||||
oo opportunities fix-titles
|
oo opportunities fix-titles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Merge two known company ids (keeps `INTO`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oo contacts merge FROM_ID INTO_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
|
||||||
|
[docs/crm-associations.md](docs/crm-associations.md).
|
||||||
|
|
||||||
|
### Invoices (`oo invoices`)
|
||||||
|
|
||||||
|
**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
|
||||||
|
attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always pass --opportunity at create (update --opportunity often HTTP 400)
|
||||||
|
oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
|
||||||
|
--price 300 --opportunity DEAL_ID --language de-DE \
|
||||||
|
--line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
|
||||||
|
|
||||||
|
oo invoices pdf INVOICE_ID --force
|
||||||
|
oo invoices pdf-cleanup INVOICE_ID
|
||||||
|
oo invoices status INVOICE_ID --status draft
|
||||||
|
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
|
||||||
|
```
|
||||||
|
|
||||||
**Deal grouping flag** — when the same role at the same company created
|
**Deal grouping flag** — when the same role at the same company created
|
||||||
separate deals (`Engineer @ Acme` vs `Engineer`):
|
separate deals (`Engineer @ Acme` vs `Engineer`):
|
||||||
|
|
||||||
@@ -728,45 +748,11 @@ oo crm cleanup --ignore-company-suffix
|
|||||||
**Inspect before/after:**
|
**Inspect before/after:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
oo opportunities list --count 200 | grep -i contoso
|
oo opportunities list --count 200
|
||||||
oo contacts get 857 -o json
|
oo contacts get CONTACT_ID -o json
|
||||||
oo crm cleanup -o json
|
oo crm cleanup -o json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Job applications → CRM (`applications sync`)
|
|
||||||
|
|
||||||
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
|
|
||||||
and want companies, persons, deals, and history notes in OnlyOffice without
|
|
||||||
re-typing.
|
|
||||||
|
|
||||||
**Dry-run first** (default — prints what would happen, writes nothing):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
oo applications sync --path ./applications/2026 --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
**Apply** when the preview looks right:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
oo applications sync --path ./applications/2026 --apply --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
Each dossier `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
|
|
||||||
Discovery walks `--path` and **skips** junk trees (`node_modules`, `tools`, `.venv`,
|
|
||||||
`pdfs`, …). Only folders that look like CV application slugs (`source-NNN-…`, or
|
|
||||||
long hyphenated dossiers) are synced — not npm package READMEs.
|
|
||||||
|
|
||||||
The sync creates or finds contacts, opens a deal, adds members, and appends a
|
|
||||||
history note. Re-running is safe: duplicate members and duplicate deal titles
|
|
||||||
are skipped when already present.
|
|
||||||
|
|
||||||
**After a large sync**, run CRM cleanup to collapse duplicates introduced by
|
|
||||||
repeated runs or manual edits:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
oo applications sync --path ./applications/2026 --apply
|
|
||||||
oo crm cleanup -o json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workspace mail (`oo mails`)
|
### Workspace mail (`oo mails`)
|
||||||
|
|
||||||
@@ -799,8 +785,21 @@ oo mails get 5664 -o json | jq '{subject, from, to, date}'
|
|||||||
# Remove one or more messages (server moves to trash or deletes per Mail rules)
|
# Remove one or more messages (server moves to trash or deletes per Mail rules)
|
||||||
oo mails delete 5664
|
oo mails delete 5664
|
||||||
oo mails delete 5664 5663 5661
|
oo mails delete 5664 5663 5661
|
||||||
|
|
||||||
|
# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
|
||||||
|
oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
|
||||||
|
--body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
|
||||||
|
|
||||||
|
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
|
||||||
|
oo mails attach 7301 --file-id 12345
|
||||||
|
|
||||||
|
# Regenerate invoice PDF (force) + draft + attach (does not send)
|
||||||
|
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Matrix / chat URLs in signatures: use plain text
|
||||||
|
`chat: https://matrix.to/#/@user:server` — HTML `<a href="…#…">` truncates at `#`.
|
||||||
|
|
||||||
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
||||||
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
||||||
API payload.
|
API payload.
|
||||||
@@ -840,8 +839,8 @@ oo calendar events --start 2026-06-24 --end 2026-07-01
|
|||||||
# Schedule interview block
|
# Schedule interview block
|
||||||
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
|
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
|
||||||
|
|
||||||
# Attach CV to a hiring task
|
# Attach a file to a task
|
||||||
oo tasks files upload 208 ./cv.pdf
|
oo tasks files upload 208 ./notes.pdf
|
||||||
oo projects files list 33
|
oo projects files list 33
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -849,7 +848,7 @@ oo projects files list 33
|
|||||||
|
|
||||||
| When | Command |
|
| When | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| After `applications sync --apply` | `oo crm cleanup` |
|
| After bulk CRM imports | `oo crm cleanup` |
|
||||||
| After bulk CSV import into CRM | `oo crm cleanup` |
|
| After bulk CSV import into CRM | `oo crm cleanup` |
|
||||||
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
|
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
|
||||||
| Before exec reporting | `oo opportunities list` + `oo projects list` |
|
| Before exec reporting | `oo opportunities list` + `oo projects list` |
|
||||||
@@ -863,9 +862,9 @@ oo projects files list 33
|
|||||||
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||||
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
|
| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` |
|
||||||
|
|
||||||
Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above.
|
Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling.
|
||||||
|
|
||||||
### CI / releases
|
### CI / releases
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyResult summarizes one apply pass.
|
||||||
|
type ApplyResult struct {
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyApproved creates/updates OO contacts for entries with approve=true.
|
||||||
|
// Companies are applied before persons so Org links resolve in the same run.
|
||||||
|
func ApplyApproved(ctx context.Context, client *onlyoffice.Client, doc *Document, dryRun bool) (*ApplyResult, error) {
|
||||||
|
res := &ApplyResult{DryRun: dryRun}
|
||||||
|
order := make([]int, 0, len(doc.Entries))
|
||||||
|
for i, e := range doc.Entries {
|
||||||
|
if e.Kind == "company" {
|
||||||
|
order = append(order, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, e := range doc.Entries {
|
||||||
|
if e.Kind != "company" {
|
||||||
|
order = append(order, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, i := range order {
|
||||||
|
e := &doc.Entries[i]
|
||||||
|
if !e.Approve {
|
||||||
|
res.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Status == "conflict" {
|
||||||
|
res.Skipped++
|
||||||
|
res.Errors = append(res.Errors, e.ID+": conflict — resolve manually")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
if e.OOID != "" || e.Status == "exists" {
|
||||||
|
res.Updated++
|
||||||
|
} else {
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created, err := applyOne(ctx, client, e)
|
||||||
|
if err != nil {
|
||||||
|
res.Errors = append(res.Errors, e.ID+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
res.Created++
|
||||||
|
} else {
|
||||||
|
res.Updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOne(ctx context.Context, client *onlyoffice.Client, e *Entry) (created bool, err error) {
|
||||||
|
switch e.Kind {
|
||||||
|
case "company":
|
||||||
|
return applyCompany(ctx, client, e)
|
||||||
|
case "person":
|
||||||
|
return applyPerson(ctx, client, e)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown kind %q", e.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||||
|
name := strings.TrimSpace(e.Name)
|
||||||
|
if name == "" {
|
||||||
|
return false, fmt.Errorf("company missing name")
|
||||||
|
}
|
||||||
|
var co map[string]any
|
||||||
|
var err error
|
||||||
|
if e.OOID != "" {
|
||||||
|
co, err = client.GetContact(ctx, e.OOID)
|
||||||
|
} else {
|
||||||
|
co, err = client.FindCompany(ctx, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
created := false
|
||||||
|
if co == nil {
|
||||||
|
co, err = client.CreateCompany(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
id := contactIDString(co)
|
||||||
|
e.OOID = id
|
||||||
|
if err := ensureContactInfos(ctx, client, id, e); err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
e.Status = "applied"
|
||||||
|
if created {
|
||||||
|
e.Notes = "created"
|
||||||
|
} else {
|
||||||
|
e.Notes = "updated"
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||||
|
first := strings.TrimSpace(e.First)
|
||||||
|
last := strings.TrimSpace(e.Last)
|
||||||
|
if first == "" && last == "" {
|
||||||
|
first, last = SplitDisplayName(e.Name)
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
first = strings.TrimSpace(e.Name)
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
return false, fmt.Errorf("person missing name")
|
||||||
|
}
|
||||||
|
if last == "" {
|
||||||
|
last = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
var p map[string]any
|
||||||
|
var err error
|
||||||
|
if e.OOID != "" {
|
||||||
|
p, err = client.GetContact(ctx, e.OOID)
|
||||||
|
} else if len(e.Emails) > 0 {
|
||||||
|
p, err = client.FindPersonByEmail(ctx, e.Emails[0])
|
||||||
|
} else {
|
||||||
|
p, err = client.FindPerson(ctx, first, last)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
created := false
|
||||||
|
companyID := 0
|
||||||
|
if e.Org != "" {
|
||||||
|
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
|
||||||
|
companyID, _ = strconv.Atoi(contactIDString(co))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
about := ""
|
||||||
|
if e.Org != "" {
|
||||||
|
about = "org: " + e.Org
|
||||||
|
}
|
||||||
|
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
id := contactIDString(p)
|
||||||
|
e.OOID = id
|
||||||
|
if err := ensureContactInfos(ctx, client, id, e); err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
e.Status = "applied"
|
||||||
|
if created {
|
||||||
|
e.Notes = "created"
|
||||||
|
} else {
|
||||||
|
e.Notes = "updated"
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactID string, e *Entry) error {
|
||||||
|
existing, err := client.GetContact(ctx, contactID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, em := range e.Emails {
|
||||||
|
if onlyoffice.HasContactInfo(existing, "Email", em) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := client.AddContactInfo(ctx, contactID, "Email", em, "Work", i == 0); err != nil {
|
||||||
|
return fmt.Errorf("add email %s: %w", em, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ph := range e.Phones {
|
||||||
|
if onlyoffice.HasContactInfo(existing, "Phone", ph) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := client.AddContactInfo(ctx, contactID, "Phone", ph, "Work", false); err != nil {
|
||||||
|
return fmt.Errorf("add phone %s: %w", ph, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVCFFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sample.vcf")
|
||||||
|
body := `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Ada Lovelace
|
||||||
|
N:Lovelace;Ada;;;
|
||||||
|
EMAIL;TYPE=INTERNET:ada@example.com
|
||||||
|
TEL;TYPE=CELL:+1-555-0100
|
||||||
|
ORG:Analytical Engines
|
||||||
|
END:VCARD
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := ParseVCFFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
e := entries[0]
|
||||||
|
if e.Kind != "person" || e.First != "Ada" || e.Last != "Lovelace" {
|
||||||
|
t.Fatalf("unexpected entry: %+v", e)
|
||||||
|
}
|
||||||
|
if len(e.Emails) != 1 || e.Emails[0] != "ada@example.com" {
|
||||||
|
t.Fatalf("emails: %v", e.Emails)
|
||||||
|
}
|
||||||
|
if e.Org != "Analytical Engines" {
|
||||||
|
t.Fatalf("org: %q", e.Org)
|
||||||
|
}
|
||||||
|
if e.Approve {
|
||||||
|
t.Fatal("approve should default false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeDocs(t *testing.T) {
|
||||||
|
a := &Document{Entries: []Entry{{
|
||||||
|
ID: "person:ada@example.com", Kind: "person", First: "Ada", Emails: []string{"ada@example.com"},
|
||||||
|
Sources: []string{"a.vcf"}, Zone: "private", Role: "unknown",
|
||||||
|
}}}
|
||||||
|
b := &Document{Entries: []Entry{{
|
||||||
|
ID: "person:ada@example.com", Kind: "person", Last: "Lovelace", Phones: []string{"+1"},
|
||||||
|
Sources: []string{"folder/Ada"}, Zone: "warm", Role: "work", Approve: true,
|
||||||
|
}}}
|
||||||
|
m := MergeDocs(a, b)
|
||||||
|
if len(m.Entries) != 1 {
|
||||||
|
t.Fatalf("len=%d", len(m.Entries))
|
||||||
|
}
|
||||||
|
e := m.Entries[0]
|
||||||
|
if e.First != "Ada" || e.Last != "Lovelace" {
|
||||||
|
t.Fatalf("%+v", e)
|
||||||
|
}
|
||||||
|
if len(e.Sources) != 2 || len(e.Phones) != 1 || !e.Approve {
|
||||||
|
t.Fatalf("%+v", e)
|
||||||
|
}
|
||||||
|
if e.Zone != "warm" || e.Role != "work" {
|
||||||
|
t.Fatalf("zone/role: %s/%s", e.Zone, e.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanContactsRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
vcfDir := filepath.Join(root, "Contacts VCF's")
|
||||||
|
if err := os.MkdirAll(vcfDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(vcfDir, "bob.vcf"), []byte(`BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Bob Builder
|
||||||
|
EMAIL:bob@work.test
|
||||||
|
END:VCARD
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
personDir := filepath.Join(root, "Carol Smith")
|
||||||
|
if err := os.MkdirAll(personDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(personDir, "carol@example.com.txt"), []byte(""), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, err := ScanContactsRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Entries) < 2 {
|
||||||
|
t.Fatalf("expected >=2 entries, got %d: %+v", len(doc.Entries), doc.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanProjectsRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repo := filepath.Join(root, "produktor-demo")
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, err := ScanProjectsRoot(root, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range doc.Entries {
|
||||||
|
if e.Kind == "company" && e.Name == "produktor-demo" {
|
||||||
|
found = true
|
||||||
|
if e.Role != "work" {
|
||||||
|
t.Fatalf("role=%q", e.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing company: %+v", doc.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryID(t *testing.T) {
|
||||||
|
if got := EntryID("person", "A@B.COM", ""); got != "person:a@b.com" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if got := EntryID("company", "", "Acme Corp"); got != "company:acme corp" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchAgainstOO sets status/oo_id by email then normalized name.
|
||||||
|
func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Document) error {
|
||||||
|
all, err := client.ListAllContacts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
byEmail := map[string]map[string]any{}
|
||||||
|
byPersonName := map[string]map[string]any{}
|
||||||
|
byCompanyName := map[string]map[string]any{}
|
||||||
|
|
||||||
|
for _, c := range all {
|
||||||
|
id := contactIDString(c)
|
||||||
|
isCo, _ := c["isCompany"].(bool)
|
||||||
|
for _, em := range contactEmails(c) {
|
||||||
|
byEmail[NormalizeEmail(em)] = c
|
||||||
|
}
|
||||||
|
if isCo {
|
||||||
|
key := NormalizeName(fmt.Sprint(c["displayName"]))
|
||||||
|
if key != "" {
|
||||||
|
byCompanyName[key] = c
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(fmt.Sprint(c["firstName"]))
|
||||||
|
last := strings.TrimSpace(fmt.Sprint(c["lastName"]))
|
||||||
|
key := NormalizeName(first + " " + last)
|
||||||
|
if key == "" {
|
||||||
|
key = NormalizeName(fmt.Sprint(c["displayName"]))
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
byPersonName[key] = c
|
||||||
|
}
|
||||||
|
_ = id
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range doc.Entries {
|
||||||
|
e := &doc.Entries[i]
|
||||||
|
// preserve approve
|
||||||
|
matched := false
|
||||||
|
conflict := false
|
||||||
|
var oo map[string]any
|
||||||
|
|
||||||
|
for _, em := range e.Emails {
|
||||||
|
if c, ok := byEmail[NormalizeEmail(em)]; ok {
|
||||||
|
if oo != nil && contactIDString(oo) != contactIDString(c) {
|
||||||
|
conflict = true
|
||||||
|
}
|
||||||
|
oo = c
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
if e.Kind == "company" {
|
||||||
|
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
|
||||||
|
oo = c
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
key := NormalizeName(strings.TrimSpace(e.First + " " + e.Last))
|
||||||
|
if key == "" {
|
||||||
|
key = NormalizeName(e.Name)
|
||||||
|
}
|
||||||
|
if c, ok := byPersonName[key]; ok {
|
||||||
|
oo = c
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflict {
|
||||||
|
e.Status = "conflict"
|
||||||
|
e.Notes = strings.TrimSpace(e.Notes + " email_matches_multiple_oo")
|
||||||
|
if oo != nil {
|
||||||
|
e.OOID = contactIDString(oo)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matched && oo != nil {
|
||||||
|
e.Status = "exists"
|
||||||
|
e.OOID = contactIDString(oo)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.Status = "new"
|
||||||
|
e.OOID = ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactIDString(c map[string]any) string {
|
||||||
|
switch v := c["id"].(type) {
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatInt(int64(v), 10)
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactEmails(c map[string]any) []string {
|
||||||
|
var out []string
|
||||||
|
if em := strings.TrimSpace(fmt.Sprint(c["email"])); em != "" && em != "<nil>" {
|
||||||
|
out = append(out, em)
|
||||||
|
}
|
||||||
|
if em := strings.TrimSpace(fmt.Sprint(c["primaryEmail"])); em != "" && em != "<nil>" {
|
||||||
|
out = append(out, em)
|
||||||
|
}
|
||||||
|
for _, row := range onlyoffice.ContactInfoRows(c) {
|
||||||
|
t := strings.ToLower(fmt.Sprint(row["infoType"]))
|
||||||
|
if t != "email" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||||
|
if data != "" {
|
||||||
|
out = append(out, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueEmails(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchAgainstOO_emailThenName(t *testing.T) {
|
||||||
|
// MatchAgainstOO needs a live client; unit-test the helpers used by matching.
|
||||||
|
c := map[string]any{
|
||||||
|
"id": float64(42),
|
||||||
|
"isCompany": false,
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Lovelace",
|
||||||
|
"email": "ada@example.com",
|
||||||
|
"commonData": []any{
|
||||||
|
map[string]any{"infoType": "Email", "data": "ada.alt@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if contactIDString(c) != "42" {
|
||||||
|
t.Fatal(contactIDString(c))
|
||||||
|
}
|
||||||
|
emails := contactEmails(c)
|
||||||
|
if len(emails) < 2 {
|
||||||
|
t.Fatalf("%v", emails)
|
||||||
|
}
|
||||||
|
}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanOptions controls Thunderbird / mbox extraction depth.
|
||||||
|
type ScanOptions struct {
|
||||||
|
// MboxHeaders, when true, also walks mbox-like files under the root and
|
||||||
|
// extracts From/To/Cc/Reply-To addresses (headers only, no bodies).
|
||||||
|
MboxHeaders bool
|
||||||
|
// MboxMaxBytes skips individual mbox files larger than this (0 = 256 MiB default).
|
||||||
|
MboxMaxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanThunderbirdRoot finds Thunderbird profiles under root and emits person rows
|
||||||
|
// from address books (*.mab) and Gloda global-messages-db.sqlite.
|
||||||
|
func ScanThunderbirdRoot(root string) (*Document, error) {
|
||||||
|
return ScanThunderbirdRootOpts(root, ScanOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanThunderbirdRootOpts is ScanThunderbirdRoot with optional mbox header pass.
|
||||||
|
func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
st, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
return nil, fmt.Errorf("not a directory: %s", root)
|
||||||
|
}
|
||||||
|
if opts.MboxMaxBytes <= 0 {
|
||||||
|
opts.MboxMaxBytes = 256 << 20
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
seenDB := map[string]struct{}{}
|
||||||
|
seenMAB := map[string]struct{}{}
|
||||||
|
seenMbox := map[string]struct{}{}
|
||||||
|
|
||||||
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if d.IsDir() {
|
||||||
|
switch name {
|
||||||
|
case "Cache", "cache2", "startupCache", "OfflineCache", "minidumps",
|
||||||
|
"crashes", "safebrowsing", "thumbnails", "chrome", "extensions",
|
||||||
|
"node_modules", ".git":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case lower == "global-messages-db.sqlite":
|
||||||
|
if _, ok := seenDB[path]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seenDB[path] = struct{}{}
|
||||||
|
parsed, perr := parseGlodaContacts(path)
|
||||||
|
if perr != nil {
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: EntryID("person", "", filepath.Base(path)),
|
||||||
|
Kind: "person",
|
||||||
|
Name: "gloda",
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: "private",
|
||||||
|
Role: "unknown",
|
||||||
|
Notes: "gloda_parse_error: " + perr.Error(),
|
||||||
|
Status: "new",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, parsed...)
|
||||||
|
case strings.HasSuffix(lower, ".mab"):
|
||||||
|
if _, ok := seenMAB[path]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seenMAB[path] = struct{}{}
|
||||||
|
parsed, perr := parseMABEmails(path)
|
||||||
|
if perr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, parsed...)
|
||||||
|
case opts.MboxHeaders && isLikelyMboxFile(name, path):
|
||||||
|
if _, ok := seenMbox[path]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seenMbox[path] = struct{}{}
|
||||||
|
info, ierr := d.Info()
|
||||||
|
if ierr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.Size() > opts.MboxMaxBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, perr := parseMboxHeaderEmails(path)
|
||||||
|
if perr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, parsed...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return MergeDocs(&Document{Entries: entries}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLikelyMboxFile(name, path string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(lower, ".msf") || strings.HasSuffix(lower, ".sqlite") ||
|
||||||
|
strings.HasSuffix(lower, ".mab") || strings.HasSuffix(lower, ".json") ||
|
||||||
|
strings.HasSuffix(lower, ".dat") || strings.HasSuffix(lower, ".ini") ||
|
||||||
|
strings.HasSuffix(lower, ".log") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbx") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Thunderbird: …/ImapMail/<server>/INBOX or …/Mail/Local Folders/Inbox
|
||||||
|
// or …/INBOX.sbd/<folder>
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
norm := filepath.ToSlash(path)
|
||||||
|
if strings.Contains(norm, "/ImapMail/") || strings.Contains(norm, "/Mail/") {
|
||||||
|
if !strings.Contains(name, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
known := map[string]struct{}{
|
||||||
|
"inbox": {}, "sent": {}, "drafts": {}, "trash": {}, "archives": {}, "junk": {},
|
||||||
|
}
|
||||||
|
if _, ok := known[lower]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(filepath.Base(filepath.Dir(path)), ".sbd") && !strings.Contains(name, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = sep
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMboxHeaderEmails extracts addresses from From/To/Cc/Reply-To headers only.
|
||||||
|
func parseMboxHeaderEmails(path string) ([]Entry, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
emails := map[string]struct{}{}
|
||||||
|
br := bufio.NewReaderSize(f, 1<<20)
|
||||||
|
inHeaders := false
|
||||||
|
var headerBuf strings.Builder
|
||||||
|
|
||||||
|
flushHeaders := func() {
|
||||||
|
if headerBuf.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block := headerBuf.String()
|
||||||
|
headerBuf.Reset()
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(line))
|
||||||
|
if strings.HasPrefix(lower, "from:") || strings.HasPrefix(lower, "to:") ||
|
||||||
|
strings.HasPrefix(lower, "cc:") || strings.HasPrefix(lower, "reply-to:") ||
|
||||||
|
strings.HasPrefix(lower, "sender:") {
|
||||||
|
for _, m := range emailRE.FindAllString(line, -1) {
|
||||||
|
em := NormalizeEmail(m)
|
||||||
|
if !noisyEmail(em) {
|
||||||
|
emails[em] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := br.ReadString('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
// Classic mbox separator
|
||||||
|
if strings.HasPrefix(line, "From ") && (len(line) == 5 || line[5] != ':') {
|
||||||
|
flushHeaders()
|
||||||
|
inHeaders = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inHeaders {
|
||||||
|
trimmed := strings.TrimRight(line, "\r\n")
|
||||||
|
if trimmed == "" {
|
||||||
|
flushHeaders()
|
||||||
|
inHeaders = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerBuf.WriteString(trimmed)
|
||||||
|
headerBuf.WriteByte('\n')
|
||||||
|
// cap pathological headers
|
||||||
|
if headerBuf.Len() > 64<<10 {
|
||||||
|
flushHeaders()
|
||||||
|
inHeaders = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
flushHeaders()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
flushHeaders()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Entry
|
||||||
|
for em := range emails {
|
||||||
|
org, zone, role := classifyMailIdentity("", em)
|
||||||
|
out = append(out, Entry{
|
||||||
|
ID: EntryID("person", em, ""),
|
||||||
|
Kind: "person",
|
||||||
|
Emails: []string{em},
|
||||||
|
Org: org,
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: zone,
|
||||||
|
Role: role,
|
||||||
|
Approve: false,
|
||||||
|
Status: "new",
|
||||||
|
Notes: "thunderbird_mbox_header",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMboxHeaderEmails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "INBOX")
|
||||||
|
body := `From - Mon Jul 1 00:00:00 2016
|
||||||
|
From: Axel Schaefer <axel.schaefer@wheregroup.com>
|
||||||
|
To: Andriy Oblivantsev <andriy.oblivantsev@wheregroup.com>
|
||||||
|
Cc: noreply@example.com, client@stadt-example.de
|
||||||
|
Subject: test
|
||||||
|
|
||||||
|
Body line ignored
|
||||||
|
From - Mon Jul 2 00:00:00 2016
|
||||||
|
From: Someone <paul.schmidt@wheregroup.com>
|
||||||
|
To: list@wheregroup.com
|
||||||
|
|
||||||
|
more body
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ents, err := parseMboxHeaderEmails(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, e := range ents {
|
||||||
|
if len(e.Emails) > 0 {
|
||||||
|
got[e.Emails[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got["axel.schaefer@wheregroup.com"] || !got["andriy.oblivantsev@wheregroup.com"] {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if got["noreply@example.com"] {
|
||||||
|
t.Fatal("noreply should be filtered")
|
||||||
|
}
|
||||||
|
if !got["client@stadt-example.de"] {
|
||||||
|
t.Fatal("missing client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanThunderbirdRootOptsMbox(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
imap := filepath.Join(root, "ImapMail", "mail.example.com")
|
||||||
|
if err := os.MkdirAll(imap, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(imap, "INBOX"), []byte(
|
||||||
|
"From - x\nFrom: a@wheregroup.com\nTo: b@wheregroup.com\n\nbody\n",
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, err := ScanThunderbirdRootOpts(root, ScanOptions{MboxHeaders: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Entries) < 2 {
|
||||||
|
t.Fatalf("%+v", doc.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanProjectsRoot finds git roots under root (max depth) and emits company rows.
|
||||||
|
func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
if maxDepth <= 0 {
|
||||||
|
maxDepth = 4
|
||||||
|
}
|
||||||
|
st, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
return nil, fmt.Errorf("not a directory: %s", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
err = walkGitRoots(root, root, 0, maxDepth, &entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also top-level dirs as company stubs (even without git) — coverage 1B.
|
||||||
|
dents, _ := os.ReadDir(root)
|
||||||
|
for _, d := range dents {
|
||||||
|
if !d.IsDir() || strings.HasPrefix(d.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
skip := map[string]struct{}{
|
||||||
|
"node_modules": {}, "vendor": {}, ".cache": {},
|
||||||
|
}
|
||||||
|
if _, ok := skip[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
id := EntryID("company", "", name)
|
||||||
|
role, zone := classifyProjectName(name, "")
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: id,
|
||||||
|
Kind: "company",
|
||||||
|
Name: name,
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: zone,
|
||||||
|
Role: role,
|
||||||
|
Status: "new",
|
||||||
|
Notes: "top_level_dir",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return MergeDocs(&Document{Entries: entries}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
|
||||||
|
if depth > maxDepth {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// is this a git root?
|
||||||
|
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (st.IsDir() || st.Mode().IsRegular()) {
|
||||||
|
name := filepath.Base(dir)
|
||||||
|
remote := gitRemoteOrigin(dir)
|
||||||
|
role, zone := classifyProjectName(name, remote)
|
||||||
|
*out = append(*out, Entry{
|
||||||
|
ID: EntryID("company", "", name),
|
||||||
|
Kind: "company",
|
||||||
|
Name: name,
|
||||||
|
Sources: []string{dir},
|
||||||
|
Remote: remote,
|
||||||
|
GitRoot: dir,
|
||||||
|
Zone: zone,
|
||||||
|
Role: role,
|
||||||
|
Status: "new",
|
||||||
|
})
|
||||||
|
return nil // do not descend into nested repos from a git root
|
||||||
|
}
|
||||||
|
dents, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, d := range dents {
|
||||||
|
if !d.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRemoteOrigin(dir string) string {
|
||||||
|
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
|
||||||
|
b, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyProjectName(name, remote string) (role, zone string) {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
remoteL := strings.ToLower(remote)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
|
||||||
|
return "experiment", "cold"
|
||||||
|
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
|
||||||
|
return "personal", "private"
|
||||||
|
case strings.Contains(remoteL, "git.produktor.io") || strings.Contains(remoteL, "github.com/eslider"):
|
||||||
|
return "work", "hot"
|
||||||
|
case strings.Contains(lower, "produktor") || strings.Contains(lower, "eslider") ||
|
||||||
|
strings.Contains(lower, "asesoria") || strings.Contains(lower, "dyvenia") ||
|
||||||
|
strings.Contains(lower, "onlyoffice"):
|
||||||
|
return "work", "warm"
|
||||||
|
default:
|
||||||
|
return "unknown", "warm"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var emailRE = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||||
|
|
||||||
|
// noisyEmail reports addresses that should not become CRM catalog persons.
|
||||||
|
func noisyEmail(email string) bool {
|
||||||
|
e := NormalizeEmail(email)
|
||||||
|
if e == "" || !strings.Contains(e, "@") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
local, domain, ok := strings.Cut(e, "@")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
noiseLocal := []string{
|
||||||
|
"noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster",
|
||||||
|
"bounce", "notifications", "newsletter", "unsubscribe",
|
||||||
|
"root", "admin", "abuse", "webmaster", "hostmaster",
|
||||||
|
}
|
||||||
|
for _, p := range noiseLocal {
|
||||||
|
if local == p || strings.Contains(local, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// role / shared mailboxes (keep human dotted names)
|
||||||
|
roleExact := map[string]struct{}{
|
||||||
|
"info": {}, "alle": {}, "all": {}, "office": {}, "office@": {},
|
||||||
|
"wartung": {}, "starface": {}, "gitlab": {}, "pl": {}, "gf": {},
|
||||||
|
"umsetzung": {}, "support": {}, "sales": {}, "billing": {},
|
||||||
|
}
|
||||||
|
if _, ok := roleExact[local]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(local, "-request") || strings.HasSuffix(local, "-owner") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
noiseDomain := []string{
|
||||||
|
"marketplace.amazon.", "reply.github.com", "users.noreply.github.com",
|
||||||
|
"groups.facebook.com", "googlegroups.com", "yahoogroups.",
|
||||||
|
"email.apple.com", "amazonses.com", "mailchimp.com",
|
||||||
|
"sendgrid.net", "mailgun.org", "postmarkapp.com",
|
||||||
|
"property.booking.com", "transvascular.com",
|
||||||
|
}
|
||||||
|
for _, p := range noiseDomain {
|
||||||
|
if strings.Contains(domain, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// random marketplace / tracking locals
|
||||||
|
if len(local) >= 16 && !strings.Contains(local, ".") && !strings.Contains(local, "-") {
|
||||||
|
if strings.Contains(domain, "amazon.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMABEmails(path string) ([]Entry, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := emailRE.FindAllString(string(b), -1)
|
||||||
|
var out []Entry
|
||||||
|
for _, raw := range found {
|
||||||
|
em := NormalizeEmail(raw)
|
||||||
|
if noisyEmail(em) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
org, zone, role := classifyMailIdentity("", em)
|
||||||
|
out = append(out, Entry{
|
||||||
|
ID: EntryID("person", em, ""),
|
||||||
|
Kind: "person",
|
||||||
|
Emails: []string{em},
|
||||||
|
Org: org,
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: zone,
|
||||||
|
Role: role,
|
||||||
|
Approve: false,
|
||||||
|
Status: "new",
|
||||||
|
Notes: "thunderbird_mab",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGlodaContacts(dbPath string) ([]Entry, error) {
|
||||||
|
// read-only URI; immutable=1 helps when WAL/shm are missing
|
||||||
|
dsn := "file:" + dbPath + "?mode=ro&_pragma=query_only(1)"
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT COALESCE(c.name, ''), i.value
|
||||||
|
FROM identities i
|
||||||
|
LEFT JOIN contacts c ON c.id = i.contactID
|
||||||
|
WHERE lower(i.kind) = 'email' AND i.value IS NOT NULL AND i.value != ''
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
var name, value string
|
||||||
|
if err := rows.Scan(&name, &value); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
em := NormalizeEmail(value)
|
||||||
|
// Gloda sometimes stores "Name <email>" in value
|
||||||
|
if m := emailRE.FindString(em); m != "" {
|
||||||
|
em = NormalizeEmail(m)
|
||||||
|
}
|
||||||
|
if noisyEmail(em) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
display := strings.TrimSpace(name)
|
||||||
|
// strip wrapping quotes / email leftovers
|
||||||
|
display = strings.Trim(display, `"'`)
|
||||||
|
if idx := strings.Index(display, "<"); idx > 0 {
|
||||||
|
display = strings.TrimSpace(display[:idx])
|
||||||
|
}
|
||||||
|
first, last := "", ""
|
||||||
|
if display != "" {
|
||||||
|
first, last = SplitDisplayName(display)
|
||||||
|
}
|
||||||
|
org, zone, role := classifyMailIdentity(display, em)
|
||||||
|
out = append(out, Entry{
|
||||||
|
ID: EntryID("person", em, display),
|
||||||
|
Kind: "person",
|
||||||
|
Name: display,
|
||||||
|
First: first,
|
||||||
|
Last: last,
|
||||||
|
Emails: []string{em},
|
||||||
|
Org: org,
|
||||||
|
Sources: []string{dbPath},
|
||||||
|
Zone: zone,
|
||||||
|
Role: role,
|
||||||
|
Approve: false,
|
||||||
|
Status: "new",
|
||||||
|
Notes: "thunderbird_gloda",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyMailIdentity(name, email string) (org, zone, role string) {
|
||||||
|
em := NormalizeEmail(email)
|
||||||
|
_, domain, _ := strings.Cut(em, "@")
|
||||||
|
switch {
|
||||||
|
case domain == "wheregroup.com" || strings.Contains(strings.ToLower(name), "wheregroup"):
|
||||||
|
return "WhereGroup", "warm", "work"
|
||||||
|
case domain == "produktor.io" || domain == "eslider.de" || strings.HasSuffix(domain, ".produktor.io"):
|
||||||
|
return "produktor.io", "hot", "work"
|
||||||
|
case domain == "dyvenia.com":
|
||||||
|
return "Dyvenia", "warm", "work"
|
||||||
|
case domain == "immowelt.de" || domain == "immowelt.com":
|
||||||
|
return "Immowelt", "warm", "work"
|
||||||
|
case strings.HasSuffix(domain, ".de") && looksPublicSector(domain):
|
||||||
|
return domain, "warm", "work"
|
||||||
|
default:
|
||||||
|
return "", "private", "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksPublicSector(domain string) bool {
|
||||||
|
d := strings.ToLower(domain)
|
||||||
|
hints := []string{
|
||||||
|
"stadt-", "stadt.", "kreis-", "gemeinde", "landkreis",
|
||||||
|
"bund.", "bundes", "lvermgeo", "rvr.", "eba.",
|
||||||
|
}
|
||||||
|
for _, h := range hints {
|
||||||
|
if strings.Contains(d, h) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNoisyEmail(t *testing.T) {
|
||||||
|
if !noisyEmail("noreply@example.com") {
|
||||||
|
t.Fatal("expected noisy")
|
||||||
|
}
|
||||||
|
if !noisyEmail("x@marketplace.amazon.de") {
|
||||||
|
t.Fatal("amazon marketplace")
|
||||||
|
}
|
||||||
|
if noisyEmail("andriy.oblivantsev@wheregroup.com") {
|
||||||
|
t.Fatal("should keep wheregroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMABEmails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "abook.mab")
|
||||||
|
body := `// mork junk
|
||||||
|
PrimaryEmail=andriy.oblivantsev@wheregroup.com
|
||||||
|
noreply@github.com
|
||||||
|
axel.schaefer@wheregroup.com
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ents, err := parseMABEmails(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ents) != 2 {
|
||||||
|
t.Fatalf("got %d: %+v", len(ents), ents)
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if e.Org != "WhereGroup" || e.Role != "work" {
|
||||||
|
t.Fatalf("%+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGlodaContacts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "global-messages-db.sqlite")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE contacts (id INTEGER PRIMARY KEY, name TEXT);
|
||||||
|
CREATE TABLE identities (id INTEGER PRIMARY KEY, contactID INTEGER, kind TEXT, value TEXT);
|
||||||
|
INSERT INTO contacts VALUES (1, 'Axel Schaefer');
|
||||||
|
INSERT INTO identities VALUES (1, 1, 'email', 'axel.schaefer@wheregroup.com');
|
||||||
|
INSERT INTO contacts VALUES (2, 'Noise Bot');
|
||||||
|
INSERT INTO identities VALUES (2, 2, 'email', 'noreply@example.com');
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
ents, err := parseGlodaContacts(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ents) != 1 {
|
||||||
|
t.Fatalf("got %d %+v", len(ents), ents)
|
||||||
|
}
|
||||||
|
if ents[0].First != "Axel" || ents[0].Emails[0] != "axel.schaefer@wheregroup.com" {
|
||||||
|
t.Fatalf("%+v", ents[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanThunderbirdRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
prof := filepath.Join(root, "profile")
|
||||||
|
if err := os.MkdirAll(prof, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@wheregroup.com\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc, err := ScanThunderbirdRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Entries) < 1 {
|
||||||
|
t.Fatal(doc.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package catalog builds a YAML inventory of CRM persons/companies from
|
||||||
|
// filesystem contacts (VCF, folders) and project trees, then matches/applies
|
||||||
|
// against OnlyOffice CRM.
|
||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one catalog row (person or company).
|
||||||
|
type Entry struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Kind string `yaml:"kind"` // person | company
|
||||||
|
Name string `yaml:"name,omitempty"`
|
||||||
|
First string `yaml:"first,omitempty"`
|
||||||
|
Last string `yaml:"last,omitempty"`
|
||||||
|
Emails []string `yaml:"emails,omitempty"`
|
||||||
|
Phones []string `yaml:"phones,omitempty"`
|
||||||
|
Org string `yaml:"org,omitempty"`
|
||||||
|
Sources []string `yaml:"sources,omitempty"`
|
||||||
|
Zone string `yaml:"zone"`
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
OOID string `yaml:"oo_id,omitempty"`
|
||||||
|
Approve bool `yaml:"approve"`
|
||||||
|
Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped
|
||||||
|
Notes string `yaml:"notes,omitempty"`
|
||||||
|
Remote string `yaml:"remote,omitempty"`
|
||||||
|
GitRoot string `yaml:"git_root,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document is the on-disk catalog file.
|
||||||
|
type Document struct {
|
||||||
|
GeneratedAt string `yaml:"generated_at"`
|
||||||
|
Entries []Entry `yaml:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadYAML reads a catalog document from path.
|
||||||
|
func LoadYAML(path string) (*Document, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var doc Document
|
||||||
|
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveYAML writes the catalog document.
|
||||||
|
func SaveYAML(path string, doc *Document) error {
|
||||||
|
doc.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
b, err := yaml.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail lowercases and trims.
|
||||||
|
func NormalizeEmail(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeName collapses whitespace and lowercases for matching.
|
||||||
|
func NormalizeName(s string) string {
|
||||||
|
fields := strings.Fields(strings.ToLower(strings.TrimSpace(s)))
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitDisplayName splits "First Last …" into first/last (last = remainder).
|
||||||
|
func SplitDisplayName(name string) (first, last string) {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(name))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0], ""
|
||||||
|
}
|
||||||
|
return parts[0], strings.Join(parts[1:], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntryID builds a stable-ish id from kind + email or name.
|
||||||
|
func EntryID(kind, email, name string) string {
|
||||||
|
if e := NormalizeEmail(email); e != "" {
|
||||||
|
return fmt.Sprintf("%s:%s", kind, e)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s", kind, NormalizeName(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeDocs unions entries by id; later sources append sources[] and fill empties.
|
||||||
|
func MergeDocs(docs ...*Document) *Document {
|
||||||
|
byID := map[string]*Entry{}
|
||||||
|
order := []string{}
|
||||||
|
for _, d := range docs {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range d.Entries {
|
||||||
|
e := d.Entries[i]
|
||||||
|
id := e.ID
|
||||||
|
if id == "" {
|
||||||
|
primary := ""
|
||||||
|
if len(e.Emails) > 0 {
|
||||||
|
primary = e.Emails[0]
|
||||||
|
}
|
||||||
|
name := e.Name
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(e.First + " " + e.Last)
|
||||||
|
}
|
||||||
|
id = EntryID(e.Kind, primary, name)
|
||||||
|
e.ID = id
|
||||||
|
}
|
||||||
|
if prev, ok := byID[id]; ok {
|
||||||
|
mergeEntry(prev, &e)
|
||||||
|
} else {
|
||||||
|
cp := e
|
||||||
|
byID[id] = &cp
|
||||||
|
order = append(order, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := &Document{Entries: make([]Entry, 0, len(order))}
|
||||||
|
for _, id := range order {
|
||||||
|
out.Entries = append(out.Entries, *byID[id])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEntry(dst, src *Entry) {
|
||||||
|
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
|
||||||
|
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
|
||||||
|
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
|
||||||
|
if dst.First == "" {
|
||||||
|
dst.First = src.First
|
||||||
|
}
|
||||||
|
if dst.Last == "" {
|
||||||
|
dst.Last = src.Last
|
||||||
|
}
|
||||||
|
if dst.Name == "" {
|
||||||
|
dst.Name = src.Name
|
||||||
|
}
|
||||||
|
if dst.Org == "" {
|
||||||
|
dst.Org = src.Org
|
||||||
|
}
|
||||||
|
if dst.Remote == "" {
|
||||||
|
dst.Remote = src.Remote
|
||||||
|
}
|
||||||
|
if dst.GitRoot == "" {
|
||||||
|
dst.GitRoot = src.GitRoot
|
||||||
|
}
|
||||||
|
if dst.Notes == "" {
|
||||||
|
dst.Notes = src.Notes
|
||||||
|
}
|
||||||
|
// Prefer more specific zone/role from project scans over default private.
|
||||||
|
if dst.Zone == "private" && src.Zone != "" && src.Zone != "private" {
|
||||||
|
dst.Zone = src.Zone
|
||||||
|
}
|
||||||
|
if dst.Role == "" || (dst.Role == "unknown" && src.Role != "" && src.Role != "unknown") {
|
||||||
|
dst.Role = src.Role
|
||||||
|
}
|
||||||
|
// Preserve approve/oo_id/status from whichever already set.
|
||||||
|
if !dst.Approve && src.Approve {
|
||||||
|
dst.Approve = true
|
||||||
|
}
|
||||||
|
if dst.OOID == "" {
|
||||||
|
dst.OOID = src.OOID
|
||||||
|
}
|
||||||
|
if dst.Status == "" {
|
||||||
|
dst.Status = src.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueEmails(in []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, e := range in {
|
||||||
|
e = NormalizeEmail(e)
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[e]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e] = struct{}{}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(in []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, s := range in {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[s]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-vcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVCFFile extracts person candidates from a vCard file (may contain many cards).
|
||||||
|
func ParseVCFFile(path string) ([]Entry, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
dec := vcard.NewDecoder(f)
|
||||||
|
var out []Entry
|
||||||
|
for {
|
||||||
|
card, err := dec.Decode()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return out, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
e := entryFromCard(card, path)
|
||||||
|
if e.Name == "" && e.First == "" && len(e.Emails) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryFromCard(card vcard.Card, source string) Entry {
|
||||||
|
fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName))
|
||||||
|
n := card.Name()
|
||||||
|
first, last := "", ""
|
||||||
|
if n != nil {
|
||||||
|
first = strings.TrimSpace(n.GivenName)
|
||||||
|
last = strings.TrimSpace(n.FamilyName)
|
||||||
|
}
|
||||||
|
if fn == "" {
|
||||||
|
fn = strings.TrimSpace(first + " " + last)
|
||||||
|
}
|
||||||
|
if first == "" && last == "" && fn != "" {
|
||||||
|
first, last = SplitDisplayName(fn)
|
||||||
|
}
|
||||||
|
var emails, phones []string
|
||||||
|
for _, v := range card.Values(vcard.FieldEmail) {
|
||||||
|
if e := NormalizeEmail(v); e != "" {
|
||||||
|
emails = append(emails, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range card.Values(vcard.FieldTelephone) {
|
||||||
|
p := strings.TrimSpace(v)
|
||||||
|
if p != "" {
|
||||||
|
phones = append(phones, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
org := strings.TrimSpace(card.PreferredValue(vcard.FieldOrganization))
|
||||||
|
primary := ""
|
||||||
|
if len(emails) > 0 {
|
||||||
|
primary = emails[0]
|
||||||
|
}
|
||||||
|
return Entry{
|
||||||
|
ID: EntryID("person", primary, fn),
|
||||||
|
Kind: "person",
|
||||||
|
Name: fn,
|
||||||
|
First: first,
|
||||||
|
Last: last,
|
||||||
|
Emails: uniqueEmails(emails),
|
||||||
|
Phones: uniqueStrings(phones),
|
||||||
|
Org: org,
|
||||||
|
Sources: []string{source},
|
||||||
|
Zone: "private",
|
||||||
|
Role: "unknown",
|
||||||
|
Approve: false,
|
||||||
|
Status: "new",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanContactsRoot walks a contacts directory: *.vcf anywhere, person-named
|
||||||
|
// subdirs, and *@*.txt email hint files.
|
||||||
|
func ScanContactsRoot(root string) (*Document, error) {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
st, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
return nil, fmt.Errorf("not a directory: %s", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
seenVCF := map[string]struct{}{}
|
||||||
|
|
||||||
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // skip unreadable
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
base := d.Name()
|
||||||
|
if base == ".git" || base == "node_modules" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(lower, ".vcf") {
|
||||||
|
if _, ok := seenVCF[path]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seenVCF[path] = struct{}{}
|
||||||
|
parsed, perr := ParseVCFFile(path)
|
||||||
|
if perr != nil {
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: EntryID("person", "", filepath.Base(path)),
|
||||||
|
Kind: "person",
|
||||||
|
Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: "private",
|
||||||
|
Role: "unknown",
|
||||||
|
Notes: "vcf_parse_error: " + perr.Error(),
|
||||||
|
Status: "new",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, parsed...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// email hint files: name contains @ and ends with .txt
|
||||||
|
if strings.Contains(name, "@") && strings.HasSuffix(lower, ".txt") {
|
||||||
|
email := NormalizeEmail(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||||
|
if !strings.Contains(email, "@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parent := filepath.Base(filepath.Dir(path))
|
||||||
|
first, last := SplitDisplayName(parent)
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: EntryID("person", email, parent),
|
||||||
|
Kind: "person",
|
||||||
|
Name: parent,
|
||||||
|
First: first,
|
||||||
|
Last: last,
|
||||||
|
Emails: []string{email},
|
||||||
|
Sources: []string{path},
|
||||||
|
Zone: "private",
|
||||||
|
Role: "unknown",
|
||||||
|
Status: "new",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level person folders without VCF still become stub persons.
|
||||||
|
dents, _ := os.ReadDir(root)
|
||||||
|
for _, d := range dents {
|
||||||
|
if !d.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if name == "Contacts VCF's" || strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// skip obvious non-person dumps
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.Contains(lower, "vcf") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first, last := SplitDisplayName(name)
|
||||||
|
id := EntryID("person", "", name)
|
||||||
|
entries = append(entries, Entry{
|
||||||
|
ID: id,
|
||||||
|
Kind: "person",
|
||||||
|
Name: name,
|
||||||
|
First: first,
|
||||||
|
Last: last,
|
||||||
|
Sources: []string{filepath.Join(root, name)},
|
||||||
|
Zone: "private",
|
||||||
|
Role: "unknown",
|
||||||
|
Status: "new",
|
||||||
|
Notes: "folder_stub",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := MergeDocs(&Document{Entries: entries})
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadEmailsFromTxt reads loose emails from a text file (one per line or free text).
|
||||||
|
func ReadEmailsFromTxt(path string) ([]string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []string
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if strings.Contains(line, "@") && !strings.Contains(line, " ") {
|
||||||
|
out = append(out, NormalizeEmail(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueEmails(out), sc.Err()
|
||||||
|
}
|
||||||
@@ -139,6 +139,23 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
start := Time(time.Now().AddDate(0, 0, -2))
|
start := Time(time.Now().AddDate(0, 0, -2))
|
||||||
deadline := Time(time.Now().AddDate(0, 0, 2))
|
deadline := Time(time.Now().AddDate(0, 0, 2))
|
||||||
|
ms, err := c.CreateMilestone(NewMilestoneRequest{
|
||||||
|
ProjectID: *project.ID,
|
||||||
|
Title: "integration milestone",
|
||||||
|
Deadline: deadline,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMilestone: %v", err)
|
||||||
|
}
|
||||||
|
if ms == nil || ms.ID == nil {
|
||||||
|
t.Fatal("created milestone without id")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := c.DeleteMilestone(*ms.ID); err != nil {
|
||||||
|
t.Logf("cleanup DeleteMilestone: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||||
ProjectId: *project.ID,
|
ProjectId: *project.ID,
|
||||||
Title: "integration parent task",
|
Title: "integration parent task",
|
||||||
@@ -146,6 +163,7 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
|||||||
StartDate: start,
|
StartDate: start,
|
||||||
Deadline: deadline,
|
Deadline: deadline,
|
||||||
Priority: int(TaskPriorityNormal),
|
Priority: int(TaskPriorityNormal),
|
||||||
|
MilestoneId: int(*ms.ID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProjectTask: %v", err)
|
t.Fatalf("CreateProjectTask: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/catalog"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var catalogCmd = &cobra.Command{
|
||||||
|
Use: "catalog",
|
||||||
|
Short: "Inventory clients/contacts from disk; match and apply to OO CRM",
|
||||||
|
Long: `Build a YAML catalog of persons/companies from contacts folders and project
|
||||||
|
trees, match against live OnlyOffice CRM, then create/update only rows with
|
||||||
|
approve: true.
|
||||||
|
|
||||||
|
Typical flow:
|
||||||
|
oo catalog scan-contacts --root PATH -O /tmp/contacts.yaml
|
||||||
|
oo catalog scan-projects --root ~/projects -O /tmp/projects.yaml
|
||||||
|
oo catalog scan-thunderbird --root PATH -O /tmp/thunderbird.yaml
|
||||||
|
oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O docs/catalog/clients-contacts.yaml
|
||||||
|
oo catalog match -i docs/catalog/clients-contacts.yaml
|
||||||
|
# edit approve: true on pilot rows
|
||||||
|
oo catalog apply --dry-run -i docs/catalog/clients-contacts.yaml
|
||||||
|
oo catalog apply --apply -i docs/catalog/clients-contacts.yaml
|
||||||
|
`}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(catalogCmd)
|
||||||
|
catalogCmd.AddCommand(catalogScanContactsCmd())
|
||||||
|
catalogCmd.AddCommand(catalogScanProjectsCmd())
|
||||||
|
catalogCmd.AddCommand(catalogScanThunderbirdCmd())
|
||||||
|
catalogCmd.AddCommand(catalogMergeCmd())
|
||||||
|
catalogCmd.AddCommand(catalogMatchCmd())
|
||||||
|
catalogCmd.AddCommand(catalogApplyCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogScanContactsCmd() *cobra.Command {
|
||||||
|
var outPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scan-contacts",
|
||||||
|
Short: "Parse VCF + folder stubs + email txt under a contacts root",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root, _ := cmd.Flags().GetString("root")
|
||||||
|
if root == "" {
|
||||||
|
return fmt.Errorf("--root is required")
|
||||||
|
}
|
||||||
|
doc, err := catalog.ScanContactsRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeCatalog(cmd, doc, outPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().String("root", "", "contacts directory (local path)")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path (default: stdout summary)")
|
||||||
|
_ = cmd.MarkFlagRequired("root")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogScanProjectsCmd() *cobra.Command {
|
||||||
|
var outPath string
|
||||||
|
var maxDepth int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scan-projects",
|
||||||
|
Short: "Git roots / remotes / top-level dirs → company rows",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root, _ := cmd.Flags().GetString("root")
|
||||||
|
if root == "" {
|
||||||
|
return fmt.Errorf("--root is required")
|
||||||
|
}
|
||||||
|
doc, err := catalog.ScanProjectsRoot(root, maxDepth)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeCatalog(cmd, doc, outPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().String("root", "", "projects directory (local path)")
|
||||||
|
cmd.Flags().IntVar(&maxDepth, "max-depth", 4, "max directory depth for git roots")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
||||||
|
_ = cmd.MarkFlagRequired("root")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogScanThunderbirdCmd() *cobra.Command {
|
||||||
|
var outPath string
|
||||||
|
var mboxHeaders bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scan-thunderbird",
|
||||||
|
Short: "Thunderbird profiles: abook/history.mab + Gloda SQLite contacts",
|
||||||
|
Long: `Walk a directory tree for Thunderbird profiles and extract person rows from:
|
||||||
|
- *.mab address books (email regex)
|
||||||
|
- global-messages-db.sqlite Gloda contacts/identities
|
||||||
|
- optional --mbox-headers: From/To/Cc/Reply-To from mbox folder files (no bodies)
|
||||||
|
|
||||||
|
Noisy senders (noreply, Amazon marketplace, GitHub reply, …) are skipped.
|
||||||
|
Default zone is private; known work domains (e.g. wheregroup.com) get zone=warm role=work.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root, _ := cmd.Flags().GetString("root")
|
||||||
|
if root == "" {
|
||||||
|
return fmt.Errorf("--root is required")
|
||||||
|
}
|
||||||
|
doc, err := catalog.ScanThunderbirdRootOpts(root, catalog.ScanOptions{MboxHeaders: mboxHeaders})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeCatalog(cmd, doc, outPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().String("root", "", "Thunderbird profile or parent directory (local path)")
|
||||||
|
cmd.Flags().BoolVar(&mboxHeaders, "mbox-headers", false, "also extract emails from mbox From/To/Cc headers")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
||||||
|
_ = cmd.MarkFlagRequired("root")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogMergeCmd() *cobra.Command {
|
||||||
|
var inputs []string
|
||||||
|
var outPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "merge",
|
||||||
|
Short: "Union + normalize catalog YAML files by entry id",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
return fmt.Errorf("at least one -i/--input required")
|
||||||
|
}
|
||||||
|
var docs []*catalog.Document
|
||||||
|
for _, p := range inputs {
|
||||||
|
d, err := catalog.LoadYAML(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
docs = append(docs, d)
|
||||||
|
}
|
||||||
|
merged := catalog.MergeDocs(docs...)
|
||||||
|
if outPath == "" {
|
||||||
|
return fmt.Errorf("--out is required for merge")
|
||||||
|
}
|
||||||
|
if err := ensureParent(outPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := catalog.SaveYAML(outPath, merged); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "merged %d entries → %s\n", len(merged.Entries), outPath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringArrayVarP(&inputs, "input", "i", nil, "input catalog YAML (repeatable)")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output catalog YAML")
|
||||||
|
_ = cmd.MarkFlagRequired("out")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogMatchCmd() *cobra.Command {
|
||||||
|
var inPath, outPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "match",
|
||||||
|
Short: "Diff catalog vs live OO; set status new|exists|conflict and oo_id",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if inPath == "" {
|
||||||
|
return fmt.Errorf("--input is required")
|
||||||
|
}
|
||||||
|
doc, err := catalog.LoadYAML(inPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := catalog.MatchAgainstOO(cmd.Context(), client, doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outPath == "" {
|
||||||
|
outPath = inPath
|
||||||
|
}
|
||||||
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, e := range doc.Entries {
|
||||||
|
counts[e.Status]++
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "matched → %s new=%d exists=%d conflict=%d\n",
|
||||||
|
outPath, counts["new"], counts["exists"], counts["conflict"])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output path (default: overwrite input)")
|
||||||
|
_ = cmd.MarkFlagRequired("input")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogApplyCmd() *cobra.Command {
|
||||||
|
var inPath, outPath string
|
||||||
|
var dryRun, doApply bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "apply",
|
||||||
|
Short: "Create/update OO contacts for approve:true rows",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !dryRun && !doApply {
|
||||||
|
return fmt.Errorf("pass --dry-run or --apply")
|
||||||
|
}
|
||||||
|
if dryRun && doApply {
|
||||||
|
return fmt.Errorf("use either --dry-run or --apply, not both")
|
||||||
|
}
|
||||||
|
if inPath == "" {
|
||||||
|
return fmt.Errorf("--input is required")
|
||||||
|
}
|
||||||
|
doc, err := catalog.LoadYAML(inPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := catalog.ApplyApproved(cmd.Context(), client, doc, dryRun)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !dryRun {
|
||||||
|
if outPath == "" {
|
||||||
|
outPath = inPath
|
||||||
|
}
|
||||||
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
printJSON(res)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mode := "apply"
|
||||||
|
if dryRun {
|
||||||
|
mode = "dry-run"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: created=%d updated=%d skipped=%d errors=%d\n",
|
||||||
|
mode, res.Created, res.Updated, res.Skipped, len(res.Errors))
|
||||||
|
for _, e := range res.Errors {
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), " error: %s\n", e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
|
||||||
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write updated catalog after apply (default: overwrite input)")
|
||||||
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "count actions without writing to OO")
|
||||||
|
cmd.Flags().BoolVar(&doApply, "apply", false, "perform OO creates/updates")
|
||||||
|
_ = cmd.MarkFlagRequired("input")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCatalog(cmd *cobra.Command, doc *catalog.Document, outPath string) error {
|
||||||
|
if outPath != "" {
|
||||||
|
if err := ensureParent(outPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "wrote %d entries → %s\n", len(doc.Entries), outPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
b, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
persons, companies := 0, 0
|
||||||
|
for _, e := range doc.Entries {
|
||||||
|
if e.Kind == "company" {
|
||||||
|
companies++
|
||||||
|
} else {
|
||||||
|
persons++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "entries=%d persons=%d companies=%d (pass -O PATH to write YAML)\n",
|
||||||
|
len(doc.Entries), persons, companies)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureParent(path string) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
+9
-2
@@ -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", "crm", "mails", "invoices",
|
||||||
}
|
}
|
||||||
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() {
|
||||||
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
help := out.String()
|
help := out.String()
|
||||||
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
|
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities", "invoices"} {
|
||||||
if !strings.Contains(help, snippet) {
|
if !strings.Contains(help, snippet) {
|
||||||
t.Fatalf("help missing %q", snippet)
|
t.Fatalf("help missing %q", snippet)
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,13 @@ func TestProjectsAlias(t *testing.T) {
|
|||||||
if cmd.Name() != "projects" {
|
if cmd.Name() != "projects" {
|
||||||
t.Fatalf("prj alias resolved to %q", cmd.Name())
|
t.Fatalf("prj alias resolved to %q", cmd.Name())
|
||||||
}
|
}
|
||||||
|
ms, _, err := rootCmd.Find([]string{"projects", "milestone-create"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ms.Name() != "milestone-create" {
|
||||||
|
t.Fatalf("milestone-create resolved to %q", ms.Name())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
|
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
|
||||||
|
|||||||
+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, mails, invoices).",
|
||||||
Version: version,
|
Version: version,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
|||||||
+323
-2
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -39,12 +40,20 @@ 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())
|
||||||
|
contactsCmd.AddCommand(contactsTagsCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagCreateCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagAddCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagRemoveCmd())
|
||||||
|
contactsCmd.AddCommand(contactsByTagCmd())
|
||||||
|
|
||||||
only := true
|
only := true
|
||||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||||
personsCmd.AddCommand(personsCreateCmd())
|
personsCmd.AddCommand(personsCreateCmd())
|
||||||
|
personsCmd.AddCommand(personsUpdateCmd())
|
||||||
|
personsCmd.AddCommand(personsFixNamesCmd())
|
||||||
personsCmd.AddCommand(contactsDeleteCmd())
|
personsCmd.AddCommand(contactsDeleteCmd())
|
||||||
personsCmd.AddCommand(personsDedupeCmd())
|
personsCmd.AddCommand(personsDedupeCmd())
|
||||||
|
|
||||||
@@ -146,6 +155,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
|
||||||
@@ -177,7 +206,7 @@ func contactsInfoAddCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func personsCreateCmd() *cobra.Command {
|
func personsCreateCmd() *cobra.Command {
|
||||||
var first, last, email, linkedin string
|
var first, last, email, linkedin, phone string
|
||||||
var companyID int
|
var companyID int
|
||||||
var jobTitle, about string
|
var jobTitle, about string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -200,6 +229,9 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
if email != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
|
if phone != "" {
|
||||||
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
|
||||||
|
}
|
||||||
if linkedin != "" {
|
if linkedin != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
||||||
}
|
}
|
||||||
@@ -213,12 +245,169 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
|
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
|
||||||
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||||
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func personsUpdateCmd() *cobra.Command {
|
||||||
|
var first, last string
|
||||||
|
var companyID int
|
||||||
|
var jobTitle, about string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update PERSON_ID",
|
||||||
|
Short: "Update a person (name, company, job title, about)",
|
||||||
|
Long: `Updates CRM person fields via PUT /crm/contact/person/{id}.
|
||||||
|
|
||||||
|
--company-id 0 leaves the employer association unchanged.
|
||||||
|
Omit --first/--last to keep current names (fetched first).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid := args[0]
|
||||||
|
cur, err := c.GetContact(cmd.Context(), pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
if v, ok := cur["firstName"].(string); ok {
|
||||||
|
first = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last == "" {
|
||||||
|
if v, ok := cur["lastName"].(string); ok {
|
||||||
|
last = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first == "" || last == "" {
|
||||||
|
return fmt.Errorf("first and last name required (pass --first/--last or ensure contact has them)")
|
||||||
|
}
|
||||||
|
out, err := c.UpdatePerson(cmd.Context(), pid, first, last, companyID, jobTitle, about)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&first, "first", "", "first name (default: current)")
|
||||||
|
cmd.Flags().StringVar(&last, "last", "", "last name (default: current)")
|
||||||
|
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id (0 = leave unchanged)")
|
||||||
|
cmd.Flags().StringVar(&jobTitle, "job-title", "", "job title")
|
||||||
|
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||||
|
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 ("Doe (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 := onlyoffice.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, phone, about, street, city, state, zip, country string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
@@ -236,12 +425,34 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
||||||
|
if about != "" || street != "" {
|
||||||
|
aboutText := about
|
||||||
|
if street != "" {
|
||||||
|
addr := strings.TrimSpace(strings.Join([]string{street, zip, city, state, country}, ", "))
|
||||||
|
if aboutText != "" {
|
||||||
|
aboutText = aboutText + "\n" + addr
|
||||||
|
} else {
|
||||||
|
aboutText = "Billing address: " + addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated, err := c.UpdateCompany(cmd.Context(), cid, name, aboutText); err == nil {
|
||||||
|
out = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
if website != "" {
|
if website != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
||||||
}
|
}
|
||||||
|
if phone != "" {
|
||||||
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Phone", phone, "Work", true)
|
||||||
|
}
|
||||||
|
if street != "" {
|
||||||
|
if _, err := c.AddContactAddress(cmd.Context(), cid, street, city, state, zip, country, "Billing", true); err != nil {
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "warning: address API failed (stored in about): %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
printObject(out)
|
printObject(out)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -249,6 +460,13 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&name, "name", "", "company name")
|
cmd.Flags().StringVar(&name, "name", "", "company name")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||||
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||||
|
cmd.Flags().StringVar(&about, "about", "", "about / notes")
|
||||||
|
cmd.Flags().StringVar(&street, "street", "", "billing street")
|
||||||
|
cmd.Flags().StringVar(&city, "city", "", "billing city")
|
||||||
|
cmd.Flags().StringVar(&state, "state", "", "billing state")
|
||||||
|
cmd.Flags().StringVar(&zip, "zip", "", "billing zip")
|
||||||
|
cmd.Flags().StringVar(&country, "country", "", "billing country")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,3 +529,106 @@ func contactsDedupeInfoCmd() *cobra.Command {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactsTagsCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "tags",
|
||||||
|
Short: "List CRM contact tags",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, err := c.ListContactTags(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printTable([]string{"title", "relativeItemsCount"}, list)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactsTagCreateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "tag-create TAG",
|
||||||
|
Short: "Create a CRM contact tag",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.CreateContactTag(cmd.Context(), args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactsTagAddCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "tag-add CONTACT_ID TAG",
|
||||||
|
Short: "Attach a tag to a contact",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.AddContactTag(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s ← %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactsTagRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "tag-remove CONTACT_ID TAG",
|
||||||
|
Short: "Remove a tag from a contact",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.RemoveContactTag(cmd.Context(), args[0], args[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s ✕ %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactsByTagCmd() *cobra.Command {
|
||||||
|
var count, offset int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "by-tag TAG",
|
||||||
|
Short: "List contacts with a given CRM tag (ignore-list = tag ignore)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, total, err := c.ListContactsByTag(cmd.Context(), args[0], count, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Printf("tag=%s total: %d (shown: %d)\n", args[0], total, len(list))
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "displayName", "isCompany", "title", "about"}, list)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||||
|
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var invoicesCmd = &cobra.Command{
|
||||||
|
Use: "invoices",
|
||||||
|
Aliases: []string{"invoice"},
|
||||||
|
Short: "CRM invoices (billing)",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(invoicesCmd)
|
||||||
|
invoicesCmd.AddCommand(invoiceListCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceGetCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceCreateCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceUpdateCmd())
|
||||||
|
invoicesCmd.AddCommand(invoicePDFCmd())
|
||||||
|
invoicesCmd.AddCommand(invoicePDFCleanupCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceStatusCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
||||||
|
invoicesCmd.AddCommand(invoiceItemsCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceListCmd() *cobra.Command {
|
||||||
|
var count, offset int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List CRM invoices",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, total, err := c.ListInvoices(cmd.Context(), count, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(list))
|
||||||
|
for _, inv := range list {
|
||||||
|
rows = append(rows, onlyoffice.FlattenInvoiceRow(inv))
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Printf("total: %d (shown: %d)\n", total, len(rows))
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "number", "cost", "statusTitle", "contactName", "issueDate", "dueDate"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||||
|
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceGetCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "get INVOICE_ID",
|
||||||
|
Short: "Show an invoice by id (incl. lines)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.GetInvoice(cmd.Context(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceCreateCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
number, issueDate, dueDate, language, currency, terms, description, po string
|
||||||
|
contactID, consigneeID, itemID, opportunityID int64
|
||||||
|
price, qty float64
|
||||||
|
lineDesc string
|
||||||
|
)
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a draft invoice with one line",
|
||||||
|
Long: `Create a CRM invoice (Draft) with a single line.
|
||||||
|
|
||||||
|
Always pass --opportunity when a deal exists (entity link at create). Updating
|
||||||
|
--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \
|
||||||
|
--price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \
|
||||||
|
--terms "…payment terms…"
|
||||||
|
`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if number == "" || contactID == 0 || itemID == 0 {
|
||||||
|
return fmt.Errorf("--number, --contact and --item are required")
|
||||||
|
}
|
||||||
|
if issueDate == "" {
|
||||||
|
issueDate = time.Now().Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||||
|
} else if !strings.Contains(issueDate, "T") {
|
||||||
|
issueDate = issueDate + "T00:00:00.0000000+01:00"
|
||||||
|
}
|
||||||
|
if dueDate == "" {
|
||||||
|
dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||||
|
} else if !strings.Contains(dueDate, "T") {
|
||||||
|
dueDate = dueDate + "T00:00:00.0000000+01:00"
|
||||||
|
}
|
||||||
|
if qty == 0 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.CreateInvoice(cmd.Context(), onlyoffice.CreateInvoiceParams{
|
||||||
|
Number: number,
|
||||||
|
IssueDate: issueDate,
|
||||||
|
DueDate: dueDate,
|
||||||
|
ContactID: contactID,
|
||||||
|
ConsigneeID: consigneeID,
|
||||||
|
EntityID: opportunityID,
|
||||||
|
EntityType: 0, // Opportunity
|
||||||
|
Language: language,
|
||||||
|
Currency: currency,
|
||||||
|
ExchangeRate: 1,
|
||||||
|
PurchaseOrderNumber: po,
|
||||||
|
Terms: terms,
|
||||||
|
Description: description,
|
||||||
|
Lines: []onlyoffice.InvoiceLine{{
|
||||||
|
InvoiceItemID: itemID,
|
||||||
|
Description: lineDesc,
|
||||||
|
Quantity: qty,
|
||||||
|
Price: price,
|
||||||
|
SortOrder: 0,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
|
||||||
|
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to company contact id (canonical company, not a PDF-only clone)")
|
||||||
|
cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
|
||||||
|
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
|
||||||
|
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
|
||||||
|
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
||||||
|
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
||||||
|
cmd.Flags().StringVar(&lineDesc, "line-description", "", "line description override")
|
||||||
|
cmd.Flags().StringVar(&issueDate, "issue-date", "", "YYYY-MM-DD (default today)")
|
||||||
|
cmd.Flags().StringVar(&dueDate, "due-date", "", "YYYY-MM-DD (default +14d)")
|
||||||
|
cmd.Flags().StringVar(&language, "language", "de-DE", "invoice language")
|
||||||
|
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency abbreviation")
|
||||||
|
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer")
|
||||||
|
cmd.Flags().StringVar(&description, "description", "", "invoice description")
|
||||||
|
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceUpdateCmd() *cobra.Command {
|
||||||
|
var opportunityID int64
|
||||||
|
var description, po, terms string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update INVOICE_ID",
|
||||||
|
Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
|
||||||
|
Long: `Update Draft invoice fields.
|
||||||
|
|
||||||
|
--opportunity often returns HTTP 400 on existing invoices. Prefer
|
||||||
|
oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
|
||||||
|
`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
|
||||||
|
return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
|
||||||
|
EntityID: opportunityID,
|
||||||
|
EntityType: 0,
|
||||||
|
Description: description,
|
||||||
|
DescriptionSet: cmd.Flags().Changed("description"),
|
||||||
|
PurchaseOrder: po,
|
||||||
|
PurchaseOrderSet: cmd.Flags().Changed("po"),
|
||||||
|
Terms: terms,
|
||||||
|
TermsSet: cmd.Flags().Changed("terms"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
|
||||||
|
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
|
||||||
|
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||||
|
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoicePDFCmd() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pdf INVOICE_ID",
|
||||||
|
Short: "Return / regenerate invoice PDF file metadata",
|
||||||
|
Long: `GET /api/2.0/crm/invoice/{id}/pdf.
|
||||||
|
|
||||||
|
With --force, touches the Draft to clear cached fileID first (layout changes).
|
||||||
|
`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if force {
|
||||||
|
out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
|
||||||
|
} else {
|
||||||
|
out, err = c.InvoicePDFFile(cmd.Context(), args[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoicePDFCleanupCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "pdf-cleanup INVOICE_ID",
|
||||||
|
Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
printObject(map[string]any{"deleted": deleted})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceStatusCmd() *cobra.Command {
|
||||||
|
var status string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status INVOICE_ID [INVOICE_ID...]",
|
||||||
|
Short: "Set invoice status (draft|billed|rejected|paid)",
|
||||||
|
Long: `PUT /api/2.0/crm/invoice/status/{id}.
|
||||||
|
|
||||||
|
Billed invoices are not content-editable. Billed→Draft often does not work —
|
||||||
|
recreate as Draft instead (docs/crm-associations.md).
|
||||||
|
`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
statusID, err := parseInvoiceStatus(status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
var id int64
|
||||||
|
if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
|
||||||
|
return fmt.Errorf("invalid invoice id %q", a)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInvoiceStatus(s string) (int, error) {
|
||||||
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
|
switch s {
|
||||||
|
case "", "draft", "1":
|
||||||
|
return onlyoffice.InvoiceStatusDraft, nil
|
||||||
|
case "billed", "2":
|
||||||
|
return onlyoffice.InvoiceStatusBilled, nil
|
||||||
|
case "rejected", "3":
|
||||||
|
return onlyoffice.InvoiceStatusRejected, nil
|
||||||
|
case "paid", "4":
|
||||||
|
return onlyoffice.InvoiceStatusPaid, nil
|
||||||
|
default:
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
||||||
|
Short: "Delete one or more invoices",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range args {
|
||||||
|
if _, err := c.DeleteInvoice(cmd.Context(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "items",
|
||||||
|
Aliases: []string{"item"},
|
||||||
|
Short: "Invoice catalog items",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(invoiceItemsListCmd())
|
||||||
|
cmd.AddCommand(invoiceItemsCreateCmd())
|
||||||
|
cmd.AddCommand(invoiceItemsDeleteCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsListCmd() *cobra.Command {
|
||||||
|
var count, offset int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List catalog invoice items",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list, total, err := c.ListInvoiceItems(cmd.Context(), count, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outputFormat == "table" {
|
||||||
|
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||||
|
for _, row := range list {
|
||||||
|
if cur, ok := row["currency"].(map[string]any); ok {
|
||||||
|
row["currency"] = cur["abbreviation"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printTable([]string{"id", "title", "price", "currency", "description"}, list)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||||
|
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsCreateCmd() *cobra.Command {
|
||||||
|
var title, description, currency string
|
||||||
|
var price float64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a catalog invoice item",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if title == "" {
|
||||||
|
return fmt.Errorf("--title is required")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.CreateInvoiceItem(cmd.Context(), title, description, price, currency)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&title, "title", "", "item title")
|
||||||
|
cmd.Flags().StringVar(&description, "description", "", "item description")
|
||||||
|
cmd.Flags().Float64Var(&price, "price", 0, "unit price")
|
||||||
|
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceItemsDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "delete ITEM_ID [ITEM_ID...]",
|
||||||
|
Short: "Delete catalog invoice items",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range args {
|
||||||
|
if _, err := c.DeleteInvoiceItem(cmd.Context(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("deleted", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-1
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,7 +13,7 @@ import (
|
|||||||
var mailsCmd = &cobra.Command{
|
var mailsCmd = &cobra.Command{
|
||||||
Use: "mails",
|
Use: "mails",
|
||||||
Aliases: []string{"mail"},
|
Aliases: []string{"mail"},
|
||||||
Short: "OnlyOffice Workspace mail — list, read, delete",
|
Short: "OnlyOffice Workspace mail — list, read, draft, delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -20,6 +22,9 @@ func init() {
|
|||||||
mailsCmd.AddCommand(mailsFoldersCmd())
|
mailsCmd.AddCommand(mailsFoldersCmd())
|
||||||
mailsCmd.AddCommand(mailsListCmd())
|
mailsCmd.AddCommand(mailsListCmd())
|
||||||
mailsCmd.AddCommand(mailsGetCmd())
|
mailsCmd.AddCommand(mailsGetCmd())
|
||||||
|
mailsCmd.AddCommand(mailsDraftCmd())
|
||||||
|
mailsCmd.AddCommand(mailsAttachCmd())
|
||||||
|
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
|
||||||
mailsCmd.AddCommand(mailsDeleteCmd())
|
mailsCmd.AddCommand(mailsDeleteCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +131,172 @@ func mailsGetCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mailsDraftCmd() *cobra.Command {
|
||||||
|
var from, to, cc, bcc, subject, body, html string
|
||||||
|
var id int64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "draft",
|
||||||
|
Short: "Create or update a mail draft (OnlyOffice Mail)",
|
||||||
|
Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save).
|
||||||
|
|
||||||
|
oo mails draft --to a@b.com --subject "…" --body "<p>…</p>"
|
||||||
|
oo mails draft --id 123 --to a@b.com --subject "…" --html "<p>…</p>"
|
||||||
|
`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("--to is required")
|
||||||
|
}
|
||||||
|
htmlBody := body
|
||||||
|
if html != "" {
|
||||||
|
htmlBody = html
|
||||||
|
}
|
||||||
|
if htmlBody == "" {
|
||||||
|
return fmt.Errorf("--body or --html is required")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
|
||||||
|
ID: id,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Cc: cc,
|
||||||
|
Bcc: bcc,
|
||||||
|
Subject: subject,
|
||||||
|
Body: onlyoffice.PlainTextToMailHTML(htmlBody),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)")
|
||||||
|
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "recipient (required)")
|
||||||
|
cmd.Flags().StringVar(&cc, "cc", "", "cc")
|
||||||
|
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
|
||||||
|
cmd.Flags().StringVar(&subject, "subject", "", "subject")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
|
||||||
|
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailsAttachCmd() *cobra.Command {
|
||||||
|
var fileID int64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "attach MESSAGE_ID",
|
||||||
|
Short: "Attach an OnlyOffice Files document to a draft/message",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if fileID <= 0 {
|
||||||
|
return fmt.Errorf("--file-id is required")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailsDraftInvoiceCmd() *cobra.Command {
|
||||||
|
var to, from, subject, body string
|
||||||
|
var invoiceID int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "draft-invoice",
|
||||||
|
Short: "Create a mail draft with regenerated invoice PDF attached",
|
||||||
|
Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF.
|
||||||
|
|
||||||
|
Does not send. Open /addons/mail/#drafts to review.
|
||||||
|
|
||||||
|
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||||
|
`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if invoiceID <= 0 || to == "" {
|
||||||
|
return fmt.Errorf("--invoice and --to are required")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fileID := onlyoffice.Int64FromMap(pdf, "id")
|
||||||
|
if fileID <= 0 {
|
||||||
|
return fmt.Errorf("invoice PDF has no file id: %+v", pdf)
|
||||||
|
}
|
||||||
|
inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
number := strings.TrimSpace(fmt.Sprint(inv["number"]))
|
||||||
|
cost := formatInvoiceCostEUR(inv["cost"])
|
||||||
|
if subject == "" {
|
||||||
|
subject = fmt.Sprintf("Invoice %s (%s EUR)", number, cost)
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
body = fmt.Sprintf(`Hello,
|
||||||
|
|
||||||
|
please find attached invoice %s for %s EUR.
|
||||||
|
|
||||||
|
Payment terms: see invoice notes.
|
||||||
|
|
||||||
|
Best regards`, number, cost)
|
||||||
|
}
|
||||||
|
draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
Body: onlyoffice.MailHTMLWithBlankParagraphs(onlyoffice.PlainTextToMailHTML(body)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mid := fmt.Sprint(draft["id"])
|
||||||
|
att, err := c.AttachMailDocument(cmd.Context(), mid, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("draft %s created but attach failed: %w", mid, err)
|
||||||
|
}
|
||||||
|
if outputFormat == "json" {
|
||||||
|
printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID)
|
||||||
|
fmt.Printf("open: %s/addons/mail/#drafts\n", strings.TrimRight(os.Getenv("ONLYOFFICE_URL"), "/"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id")
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "recipient")
|
||||||
|
cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)")
|
||||||
|
cmd.Flags().StringVar(&subject, "subject", "", "override subject")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "override plain-text body")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInvoiceCostEUR(v any) string {
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
s = strings.TrimSuffix(s, ".00")
|
||||||
|
s = strings.TrimSuffix(s, ".0")
|
||||||
|
if s == "" || s == "<nil>" {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func mailsDeleteCmd() *cobra.Command {
|
func mailsDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete ID [ID...]",
|
Use: "delete ID [ID...]",
|
||||||
|
|||||||
+5
-3
@@ -6,15 +6,17 @@
|
|||||||
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
||||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||||
// oo users list | self (alias: oo whoami)
|
// oo users list | self (alias: oo whoami)
|
||||||
// oo contacts list | get | delete | info-add | dedupe-info
|
// oo contacts list | get | delete | info-add | merge | dedupe-info
|
||||||
// oo persons list | create | delete | dedupe
|
// oo persons list | create | delete | dedupe
|
||||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||||
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||||
// oo cases list | create | delete | member-add
|
// oo cases list | create | delete | member-add
|
||||||
// oo crm-tasks list | create | delete | categories
|
// oo crm-tasks list | create | delete | categories
|
||||||
// oo crm cleanup
|
// oo crm cleanup
|
||||||
// oo mails accounts | folders | list | get | delete
|
// oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete
|
||||||
// oo applications sync
|
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||||
|
//
|
||||||
|
// CRM association rules: docs/crm-associations.md
|
||||||
//
|
//
|
||||||
// Every list supports `--output/-o json|table` (table is the default).
|
// Every list supports `--output/-o json|table` (table is the default).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func init() {
|
|||||||
opportunitiesCmd.AddCommand(oppListCmd())
|
opportunitiesCmd.AddCommand(oppListCmd())
|
||||||
opportunitiesCmd.AddCommand(oppGetCmd())
|
opportunitiesCmd.AddCommand(oppGetCmd())
|
||||||
opportunitiesCmd.AddCommand(oppCreateCmd())
|
opportunitiesCmd.AddCommand(oppCreateCmd())
|
||||||
|
opportunitiesCmd.AddCommand(oppUpdateCmd())
|
||||||
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
||||||
opportunitiesCmd.AddCommand(oppStagesCmd())
|
opportunitiesCmd.AddCommand(oppStagesCmd())
|
||||||
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
||||||
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func oppUpdateCmd() *cobra.Command {
|
||||||
|
var title, desc string
|
||||||
|
var stage int64
|
||||||
|
var bid float64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update OPPORTUNITY_ID",
|
||||||
|
Short: "Update opportunity title, description, stage, or bid",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := c.UpdateOpportunity(cmd.Context(), args[0], onlyoffice.UpdateOpportunityParams{
|
||||||
|
Title: title,
|
||||||
|
Description: desc,
|
||||||
|
StageID: stage,
|
||||||
|
BidValue: bid,
|
||||||
|
BidValueSet: cmd.Flags().Changed("bid"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(out)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&title, "title", "", "new title")
|
||||||
|
cmd.Flags().StringVar(&desc, "description", "", "new description")
|
||||||
|
cmd.Flags().Int64Var(&stage, "stage", 0, "pipeline stage id")
|
||||||
|
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func oppDeleteCmd() *cobra.Command {
|
func oppDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
|
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
|
||||||
|
|||||||
+402
-1
@@ -2,7 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -19,9 +23,11 @@ func init() {
|
|||||||
projectsCmd.AddCommand(prjListCmd())
|
projectsCmd.AddCommand(prjListCmd())
|
||||||
projectsCmd.AddCommand(prjGetCmd())
|
projectsCmd.AddCommand(prjGetCmd())
|
||||||
projectsCmd.AddCommand(prjMilestonesCmd())
|
projectsCmd.AddCommand(prjMilestonesCmd())
|
||||||
|
projectsCmd.AddCommand(prjMilestoneCreateCmd())
|
||||||
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 {
|
||||||
@@ -114,19 +120,80 @@ func prjMilestonesCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prjMilestoneCreateCmd() *cobra.Command {
|
||||||
|
var deadline, desc string
|
||||||
|
var key bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "milestone-create PROJECT_ID TITLE",
|
||||||
|
Short: "Create a project milestone (Gantt row)",
|
||||||
|
Args: cobra.ExactArgs(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 must be integer: %w", err)
|
||||||
|
}
|
||||||
|
if deadline == "" {
|
||||||
|
return fmt.Errorf("--deadline YYYY-MM-DD is required")
|
||||||
|
}
|
||||||
|
day, err := time.Parse("2006-01-02", deadline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deadline: %w", err)
|
||||||
|
}
|
||||||
|
ms, err := c.CreateMilestone(onlyoffice.NewMilestoneRequest{
|
||||||
|
ProjectID: pid,
|
||||||
|
Title: args[1],
|
||||||
|
Deadline: onlyoffice.Time(day),
|
||||||
|
Description: desc,
|
||||||
|
IsKey: key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{
|
||||||
|
"id": derefInt64(ms.ID),
|
||||||
|
"title": derefString(ms.Title),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
|
||||||
|
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||||
|
cmd.Flags().BoolVar(&key, "key", false, "mark as key milestone")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
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 = onlyoffice.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 +209,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 +229,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 +261,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 +309,314 @@ 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 — create/link them in CRM first.
|
||||||
|
|
||||||
|
Also links --company-id contacts (employer and/or 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
|
||||||
|
|
||||||
|
Then: oo projects contacts link-authors 59 --from authors.txt --company-id COMPANY_ID`,
|
||||||
|
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
|
||||||
|
|||||||
+55
-3
@@ -2,7 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,11 +86,12 @@ func taskGetCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func taskCreateCmd() *cobra.Command {
|
func taskCreateCmd() *cobra.Command {
|
||||||
var project, desc, deadline, prio string
|
var project, desc, deadline, start, prio string
|
||||||
|
var milestone int64
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create TITLE",
|
Use: "create TITLE",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
Short: "Create a project task",
|
Short: "Create a project task (Gantt bar when --start/--deadline/--milestone set)",
|
||||||
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)
|
||||||
@@ -100,6 +105,51 @@ func taskCreateCmd() *cobra.Command {
|
|||||||
case "low":
|
case "low":
|
||||||
p = -1
|
p = -1
|
||||||
}
|
}
|
||||||
|
if start != "" || milestone != 0 {
|
||||||
|
pid, err := strconv.Atoi(project)
|
||||||
|
if err != nil && project != "" {
|
||||||
|
return fmt.Errorf("project id: %w", err)
|
||||||
|
}
|
||||||
|
if project == "" {
|
||||||
|
pid, err = strconv.Atoi(os.Getenv("OO_PROJECT_ID"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--project or OO_PROJECT_ID required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := onlyoffice.NewProjectTaskRequest{
|
||||||
|
ProjectId: pid,
|
||||||
|
Title: args[0],
|
||||||
|
Description: desc,
|
||||||
|
Priority: p,
|
||||||
|
MilestoneId: int(milestone),
|
||||||
|
}
|
||||||
|
if deadline == "" {
|
||||||
|
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
d, err := time.Parse("2006-01-02", deadline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deadline: %w", err)
|
||||||
|
}
|
||||||
|
req.Deadline = onlyoffice.Time(d)
|
||||||
|
if start != "" {
|
||||||
|
s, err := time.Parse("2006-01-02", start)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
req.StartDate = onlyoffice.Time(s)
|
||||||
|
} else {
|
||||||
|
req.StartDate = onlyoffice.Time(time.Now())
|
||||||
|
}
|
||||||
|
task, err := c.CreateProjectTask(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{
|
||||||
|
"id": derefInt(task.ID),
|
||||||
|
"title": derefString(task.Title),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
|
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,7 +160,9 @@ func taskCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
||||||
cmd.Flags().StringVar(&desc, "description", "", "description")
|
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d; always assigned to you)")
|
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d)")
|
||||||
|
cmd.Flags().StringVar(&start, "start", "", "start YYYY-MM-DD (uses CreateProjectTask / Gantt)")
|
||||||
|
cmd.Flags().Int64Var(&milestone, "milestone", 0, "milestone id (Gantt row)")
|
||||||
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package onlyoffice
|
|||||||
|
|
||||||
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
|
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
|
||||||
// These expose untyped maps for flexibility — they are primarily consumed by
|
// These expose untyped maps for flexibility — they are primarily consumed by
|
||||||
// cmd/oo and the applications-sync workflow.
|
// cmd/oo and CRM sync tooling.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -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,44 @@ 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 (re-sends current
|
||||||
|
// company id when present). OnlyOffice ignores companyId/about on form-encoded
|
||||||
|
// PUT and may unlink the company when companyId is omitted — use JSON body.
|
||||||
|
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"firstName": first,
|
||||||
|
"lastName": last,
|
||||||
|
}
|
||||||
|
if companyID != 0 {
|
||||||
|
body["companyId"] = companyID
|
||||||
|
} else {
|
||||||
|
// Preserve existing employer: omitted companyId unlinks on this API.
|
||||||
|
if cur, err := c.GetContact(ctx, personID); err == nil {
|
||||||
|
if co, ok := cur["company"].(map[string]any); ok {
|
||||||
|
if id := flexInt(co["id"]); id != 0 {
|
||||||
|
body["companyId"] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jobTitle != "" {
|
||||||
|
body["jobTitle"] = jobTitle
|
||||||
|
}
|
||||||
|
if about != "" {
|
||||||
|
body["about"] = about
|
||||||
|
}
|
||||||
|
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), body)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
// PUT response body is often stale; re-fetch for authoritative fields.
|
||||||
|
if fresh, gerr := c.GetContact(ctx, personID); gerr == nil && fresh != nil {
|
||||||
|
out = fresh
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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 == "" {
|
||||||
@@ -143,6 +243,69 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
||||||
|
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
||||||
|
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactTag creates a contact tag by name. Idempotent: "already exists" is OK.
|
||||||
|
func (c *Client) CreateContactTag(ctx context.Context, tagName string) error {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("tagName", tagName)
|
||||||
|
_, err := c.postFormObject(ctx, "/api/2.0/crm/contact/tag.json", fields)
|
||||||
|
if err != nil && strings.Contains(err.Error(), "already exists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddContactTag attaches tagName to a contact.
|
||||||
|
func (c *Client) AddContactTag(ctx context.Context, contactID, tagName string) error {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("tagName", tagName)
|
||||||
|
_, err := c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveContactTag removes tagName from a contact.
|
||||||
|
func (c *Client) RemoveContactTag(ctx context.Context, contactID, tagName string) error {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("tagName", tagName)
|
||||||
|
raw, err := c.deleteForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = unmarshalResponseObject(raw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContactsByTag returns contacts filtered by a single CRM tag name.
|
||||||
|
func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("count", strconv.Itoa(count))
|
||||||
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
q.Set("tags", tagName)
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response []map[string]any `json:"response"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total := env.Total
|
||||||
|
if total == 0 && len(env.Response) > 0 {
|
||||||
|
total = len(env.Response)
|
||||||
|
}
|
||||||
|
return env.Response, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListAllContacts paginates through every CRM contact.
|
// ListAllContacts paginates through every CRM contact.
|
||||||
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
||||||
const page = 100
|
const page = 100
|
||||||
@@ -500,7 +663,7 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
|
|||||||
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||||
}
|
}
|
||||||
if categoryID == 0 {
|
if categoryID == 0 {
|
||||||
categoryID = 2 // Opportunity — matches applications sync
|
categoryID = 2 // Opportunity (default CRM task category)
|
||||||
}
|
}
|
||||||
uid, err := c.SelfUserID(ctx)
|
uid, err := c.SelfUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`)
|
||||||
|
dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`)
|
||||||
|
emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||||
|
nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanPersonNames strips company annotations from display names and fills
|
||||||
|
// first/last from email local-part when the source used an address as the name.
|
||||||
|
// Company affiliation belongs on CRM companyId — never in LastName.
|
||||||
|
func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) {
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
last = strings.TrimSpace(last)
|
||||||
|
display = strings.TrimSpace(display)
|
||||||
|
org = strings.TrimSpace(org)
|
||||||
|
|
||||||
|
if looksLikeEmail(first) {
|
||||||
|
ef, el := GuessNameFromEmail(first)
|
||||||
|
first, last = ef, el
|
||||||
|
}
|
||||||
|
if looksLikeEmail(display) && first == "" && last == "" {
|
||||||
|
display = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if first == "" && last == "" && display != "" {
|
||||||
|
first, last = SplitDisplayName(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
first = stripCompanyAnnotation(first, org)
|
||||||
|
last = stripCompanyAnnotation(last, org)
|
||||||
|
|
||||||
|
last = stripCompanyAnnotation(last, org)
|
||||||
|
if i := strings.IndexAny(first, "(["); i > 0 {
|
||||||
|
first = strings.TrimSpace(first[:i])
|
||||||
|
}
|
||||||
|
if org != "" && personLastIsOrg(last, org) {
|
||||||
|
last = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first == "" || looksLikeEmail(first)) && len(emails) > 0 {
|
||||||
|
ef, el := GuessNameFromEmail(emails[0])
|
||||||
|
if first == "" || looksLikeEmail(first) {
|
||||||
|
first = ef
|
||||||
|
}
|
||||||
|
if last == "" || last == "-" {
|
||||||
|
last = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
last = strings.TrimSpace(last)
|
||||||
|
if last == "" {
|
||||||
|
last = "-"
|
||||||
|
}
|
||||||
|
return first, last
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripCompanyAnnotation(s, org string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = parenSuffixRE.ReplaceAllString(s, "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = dashCompanyRE.ReplaceAllString(s, "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if org != "" {
|
||||||
|
for _, sep := range []string{" - ", " – ", " — ", " / "} {
|
||||||
|
if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 {
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suf := " (" + org + ")"
|
||||||
|
if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) {
|
||||||
|
s = strings.TrimSpace(s[:len(s)-len(suf)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func personLastIsOrg(last, org string) bool {
|
||||||
|
last = NormalizePersonNameKey(last)
|
||||||
|
org = NormalizePersonNameKey(org)
|
||||||
|
if last == "" || org == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if last == org {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeEmail(s string) bool {
|
||||||
|
return emailLocalRE.MatchString(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuessNameFromEmail turns local@domain into Title-Case first/last when the
|
||||||
|
// local part looks like first.last / first_last / first-last.
|
||||||
|
func GuessNameFromEmail(email string) (first, last string) {
|
||||||
|
email = NormalizeContactEmail(email)
|
||||||
|
local, _, ok := strings.Cut(email, "@")
|
||||||
|
if !ok || local == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
local = strings.Split(local, "+")[0]
|
||||||
|
parts := strings.FieldsFunc(local, func(r rune) bool {
|
||||||
|
return r == '.' || r == '_' || r == '-'
|
||||||
|
})
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return titleNameToken(local), ""
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return titleNameToken(parts[0]), ""
|
||||||
|
}
|
||||||
|
return titleNameToken(parts[0]), titleNameToken(strings.Join(parts[1:], " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleNameToken(s string) string {
|
||||||
|
s = nonNameTokenRE.ReplaceAllString(s, " ")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(strings.ToLower(s))
|
||||||
|
runes[0] = unicode.ToTitle(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatProjectTitle builds "CC | Company | Title" (spaces around |).
|
||||||
|
// Country should be a short code (DE, TF, UA, …). Empty segments are dropped.
|
||||||
|
func FormatProjectTitle(country, company, title string) string {
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
for _, p := range []string{country, company, title} {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
p = strings.ReplaceAll(p, "|", "/")
|
||||||
|
if p != "" {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeContactEmail lowercases and trims.
|
||||||
|
func NormalizeContactEmail(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePersonNameKey collapses whitespace and lowercases for matching.
|
||||||
|
func NormalizePersonNameKey(s string) string {
|
||||||
|
fields := strings.Fields(strings.ToLower(strings.TrimSpace(s)))
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitDisplayName splits "First Last …" into first/last (last = remainder).
|
||||||
|
func SplitDisplayName(name string) (first, last string) {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(name))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0], ""
|
||||||
|
}
|
||||||
|
return parts[0], strings.Join(parts[1:], " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCleanPersonNames_stripsCompanyParen(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("Jane", "Doe (Acme)", "Jane Doe (Acme)", "Acme", nil)
|
||||||
|
if f != "Jane" || l != "Doe" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNames_stripsDashCompany(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("Jane", "Doe - Acme", "", "Acme", nil)
|
||||||
|
if f != "Jane" || l != "Doe" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNames_fromEmail(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("jane.doe@example.com", "-", "", "Acme",
|
||||||
|
[]string{"jane.doe@example.com"})
|
||||||
|
if f != "Jane" || l != "Doe" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanPersonNames_lastIsCompany(t *testing.T) {
|
||||||
|
f, l := CleanPersonNames("Jane", "Acme", "", "Acme GmbH & Co. KG", nil)
|
||||||
|
if f != "Jane" || l != "-" {
|
||||||
|
t.Fatalf("got %q %q", f, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatProjectTitle(t *testing.T) {
|
||||||
|
got := FormatProjectTitle("DE", "Acme", "Map App")
|
||||||
|
if got != "DE | Acme | Map App" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-4
@@ -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.
|
||||||
@@ -52,7 +69,7 @@ func StripCompanySuffix(title string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
|
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
|
||||||
// empty-position titles like " @ contoso" to "contoso".
|
// empty-position titles like " @ Acme" to "Acme".
|
||||||
func FixDealTitle(s string) string {
|
func FixDealTitle(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
for strings.HasPrefix(s, "@") {
|
for strings.HasPrefix(s, "@") {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
|
||||||
|
|
||||||
|
Operational rules for the `oo` CLI and this library. Business SSOT remains
|
||||||
|
OnlyOffice Workspace CRM + Projects.
|
||||||
|
|
||||||
|
## Canonical graph
|
||||||
|
|
||||||
|
One **legal company** owns the relationship. Do not invent a second “bill-to”
|
||||||
|
company just for PDF layout.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Company
|
||||||
|
├── Person (buyer contact) oo persons create --company-id
|
||||||
|
├── Opportunity / Deal oo opportunities … ; member-add company + person
|
||||||
|
├── Project (hub) oo projects … ; contacts add company + person
|
||||||
|
│ └── Epic + subtasks
|
||||||
|
└── Invoice (Draft → …) oo invoices create --contact COMPANY --opportunity DEAL
|
||||||
|
└── PDF file oo invoices pdf ID
|
||||||
|
└── Mail draft oo mails draft-invoice --invoice ID --to …
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | CLI | Must link |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| Company | `oo companies create` | website, email, phone, **one** Billing address |
|
||||||
|
| Person | `oo persons create --company-id` / `oo persons update ID` | job title; never encode employer in `lastName`; **update uses JSON** (form PUT ignores `companyId`/`about`) |
|
||||||
|
| Deal | `oo opportunities create` + `member-add` | company **and** person as members |
|
||||||
|
| Project | `oo projects create` + `contacts add` | same company + person |
|
||||||
|
| Invoice | `oo invoices create --contact COMPANY --opportunity DEAL` | `entityId` at **create** |
|
||||||
|
| Mail | `oo mails draft-invoice` | attach current PDF; **do not send** until confirmed |
|
||||||
|
|
||||||
|
UI checks (same company card):
|
||||||
|
|
||||||
|
- `#contacts` → person
|
||||||
|
- `#deals` → opportunity
|
||||||
|
- `#projects` → hub project
|
||||||
|
- `#invoices` on the **deal** → invoice (needs `entity`)
|
||||||
|
- `#files` → preferably **one** current invoice PDF
|
||||||
|
|
||||||
|
**Project Team ≠ Project Contacts.** Team = portal users. CRM people/companies
|
||||||
|
show under the project **Contacts** tab (`oo projects contacts list`).
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. **One company per legal entity.** Duplicate “bill-to” contacts empty Deals /
|
||||||
|
Projects / Contacts tabs and break merge. Prefer
|
||||||
|
`oo contacts merge FROM INTO` (keeps `INTO`) or `oo companies dedupe`.
|
||||||
|
2. **Link invoice → deal at create.**
|
||||||
|
`POST /crm/invoice` with `entityId` + `entityType: 0` (Opportunity).
|
||||||
|
`oo invoices update … --opportunity` often returns **400**
|
||||||
|
(“Value does not fall within the expected range”). If the link is missing,
|
||||||
|
delete the Draft and recreate with `--opportunity`.
|
||||||
|
3. **Bill To = company id**, not a throwaway contact. Person stays under the
|
||||||
|
company (`companyId`). Optional `consigneeId` for Empfänger when the portal
|
||||||
|
template prints it.
|
||||||
|
4. **Stay Draft until mail is ready.** Billed (`status id=2`) is **not editable**
|
||||||
|
via content PUT. Going Billed → Draft via `…/crm/invoice/status/1` usually
|
||||||
|
**does not work** — delete + recreate Draft instead.
|
||||||
|
5. **Do not regenerate PDF in a loop** without cleanup. Each
|
||||||
|
`GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often
|
||||||
|
the deal). Keep `invoice.fileID`; delete older PDFs with
|
||||||
|
`oo invoices pdf-cleanup ID` / Documents `fileops/delete`.
|
||||||
|
|
||||||
|
## Invoice PDF quirks
|
||||||
|
|
||||||
|
| Symptom | Workaround |
|
||||||
|
|---------|------------|
|
||||||
|
| Cached / stale PDF | Touch invoice (Draft PUT that clears `fileID`), then `GET …/pdf` — `oo invoices pdf ID --force` |
|
||||||
|
| Billing address missing on **new** PDFs | Temporary multiline `companyName` (`Line1\nLine2\n…`) on the **canonical** company → force PDF → restore clean name. Cached `fileID` keeps the multiline Bill To. |
|
||||||
|
| Separate bill-to company for newlines | **Forbidden** — merge back to the real company |
|
||||||
|
| Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number |
|
||||||
|
| Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) |
|
||||||
|
| Issuer street lines | Organisation profile address (`street` with `\n`), not only terms |
|
||||||
|
|
||||||
|
Status ids commonly used: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
|
||||||
|
|
||||||
|
## Mail quirks
|
||||||
|
|
||||||
|
| Symptom | Workaround |
|
||||||
|
|---------|------------|
|
||||||
|
| Signature / body doubles chat URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). |
|
||||||
|
| Signature / body cuts URL at `#` | Plain text URLs — avoid `<a href="…#…">` (or encode `#` as `%23` in href) |
|
||||||
|
| German letter spacing | Blank `<p> </p>` between blocks (`MailHTMLWithBlankParagraphs`) |
|
||||||
|
| Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms |
|
||||||
|
|
||||||
|
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) for invoice delivery until confirmed.
|
||||||
|
|
||||||
|
## Project / task quirks
|
||||||
|
|
||||||
|
- Hub title: `CC | Company` (e.g. `DE | Acme GmbH`).
|
||||||
|
- Streams = epics/tasks under the hub, not a third title segment (unless the
|
||||||
|
project itself is a named delivery stream).
|
||||||
|
- Closing a **subtask**:
|
||||||
|
`PUT /api/2.0/project/task/{epicId}/{subtaskId}/status` with `status=2`.
|
||||||
|
`oo tasks update SUBTASK -s closed` returns **404** for subtasks.
|
||||||
|
- After deleting a CRM contact, `GET /project/contact/{deletedId}` may still
|
||||||
|
return projects (ghost). Official project contact list should only show live
|
||||||
|
ids; unlink may 400 if the contact is gone.
|
||||||
|
|
||||||
|
## Merge / cleanup cheat sheet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Keep the preferred company (INTO), drop the duplicate (FROM)
|
||||||
|
oo contacts merge FROM_ID INTO_ID
|
||||||
|
|
||||||
|
# Or by normalized name (careful — whole CRM)
|
||||||
|
oo companies dedupe
|
||||||
|
|
||||||
|
# Invoice ↔ deal must exist at create
|
||||||
|
oo invoices create --number P-YYYY-NN --contact COMPANY_ID --item ITEM_ID \
|
||||||
|
--price 300 --opportunity DEAL_ID --language de-DE …
|
||||||
|
|
||||||
|
# Fresh PDF + prune older PDFs on company/deal
|
||||||
|
oo invoices pdf INVOICE_ID --force
|
||||||
|
oo invoices pdf-cleanup INVOICE_ID
|
||||||
|
|
||||||
|
# Mail draft (no send)
|
||||||
|
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- README § invoices / mail / CRM cleanup
|
||||||
|
- Personal workspace tooling (disk inventory, dossier sync): private
|
||||||
|
`git.produktor.io/eSlider/oo-workspace` (`oow` CLI)
|
||||||
@@ -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.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -25,22 +28,29 @@ 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
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
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.47.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.4 // 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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
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=
|
||||||
@@ -49,8 +59,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
@@ -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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.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.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
modernc.org/cc/v4 v4.29.1/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.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
|
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.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||||
|
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (j
|
|||||||
return c.formRequest(ctx, http.MethodPut, path, fields)
|
return c.formRequest(ctx, http.MethodPut, path, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteForm issues an authenticated DELETE with application/x-www-form-urlencoded body.
|
||||||
|
func (c *Client) deleteForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
||||||
|
return c.formRequest(ctx, http.MethodDelete, path, fields)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
|
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
|
||||||
auth, err := c.authHeader()
|
auth, err := c.authHeader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,6 +249,58 @@ func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[
|
|||||||
return unmarshalResponseObject(raw)
|
return unmarshalResponseObject(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postJSONObject issues an authenticated POST with JSON body and decodes response.
|
||||||
|
func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
||||||
|
raw, err := c.postJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return unmarshalResponseObject(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON issues an authenticated POST with application/json body.
|
||||||
|
func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
|
auth, err := c.authHeader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rdr io.Reader
|
||||||
|
switch b := body.(type) {
|
||||||
|
case nil:
|
||||||
|
rdr = strings.NewReader("{}")
|
||||||
|
case []byte:
|
||||||
|
rdr = bytes.NewReader(b)
|
||||||
|
case string:
|
||||||
|
rdr = strings.NewReader(b)
|
||||||
|
default:
|
||||||
|
buf, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rdr = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
// putJSON issues an authenticated PUT with application/json body.
|
// putJSON issues an authenticated PUT with application/json body.
|
||||||
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
auth, err := c.authHeader()
|
auth, err := c.authHeader()
|
||||||
|
|||||||
+545
@@ -0,0 +1,545 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// CRM Invoices and invoice catalog items (billing).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InvoiceLine is one line on a create-invoice request.
|
||||||
|
type InvoiceLine struct {
|
||||||
|
InvoiceItemID int64 `json:"invoiceItemID"`
|
||||||
|
InvoiceTax1ID int64 `json:"invoiceTax1ID"`
|
||||||
|
InvoiceTax2ID int64 `json:"invoiceTax2ID"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Discount float64 `json:"discount"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoiceParams holds fields for POST /api/2.0/crm/invoice.
|
||||||
|
type CreateInvoiceParams struct {
|
||||||
|
Number string
|
||||||
|
IssueDate string // ISO-8601
|
||||||
|
DueDate string // ISO-8601
|
||||||
|
ContactID int64
|
||||||
|
ConsigneeID int64 // 0 = omit
|
||||||
|
EntityID int64 // 0 = omit; link to opportunity/case
|
||||||
|
EntityType int // OnlyOffice EntityType; 0 = Opportunity
|
||||||
|
Language string
|
||||||
|
Currency string
|
||||||
|
ExchangeRate float64
|
||||||
|
PurchaseOrderNumber string
|
||||||
|
Terms string
|
||||||
|
Description string
|
||||||
|
TemplateType int
|
||||||
|
Lines []InvoiceLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvoices returns a page of CRM invoices and the total count.
|
||||||
|
func (c *Client) ListInvoices(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("count", strconv.Itoa(count))
|
||||||
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/invoice/filter.json?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response []map[string]any `json:"response"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total := env.Total
|
||||||
|
if total == 0 && len(env.Response) > 0 {
|
||||||
|
total = len(env.Response)
|
||||||
|
}
|
||||||
|
return env.Response, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvoice returns a single invoice by id (includes invoiceLines).
|
||||||
|
func (c *Client) GetInvoice(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoice creates a draft invoice with the given lines.
|
||||||
|
func (c *Client) CreateInvoice(ctx context.Context, p CreateInvoiceParams) (map[string]any, error) {
|
||||||
|
if p.ContactID == 0 {
|
||||||
|
return nil, fmt.Errorf("contactId is required")
|
||||||
|
}
|
||||||
|
if p.Number == "" {
|
||||||
|
return nil, fmt.Errorf("number is required")
|
||||||
|
}
|
||||||
|
if len(p.Lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one invoice line is required")
|
||||||
|
}
|
||||||
|
if p.Language == "" {
|
||||||
|
p.Language = "de-DE"
|
||||||
|
}
|
||||||
|
if p.Currency == "" {
|
||||||
|
p.Currency = "EUR"
|
||||||
|
}
|
||||||
|
if p.ExchangeRate == 0 {
|
||||||
|
p.ExchangeRate = 1
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"number": p.Number,
|
||||||
|
"issueDate": p.IssueDate,
|
||||||
|
"dueDate": p.DueDate,
|
||||||
|
"contactId": p.ContactID,
|
||||||
|
"language": p.Language,
|
||||||
|
"currency": p.Currency,
|
||||||
|
"exchangeRate": p.ExchangeRate,
|
||||||
|
"purchaseOrderNumber": p.PurchaseOrderNumber,
|
||||||
|
"terms": p.Terms,
|
||||||
|
"description": p.Description,
|
||||||
|
"templateType": p.TemplateType,
|
||||||
|
"invoiceLines": p.Lines,
|
||||||
|
}
|
||||||
|
if p.ConsigneeID != 0 {
|
||||||
|
body["consigneeId"] = p.ConsigneeID
|
||||||
|
}
|
||||||
|
if p.EntityID != 0 {
|
||||||
|
body["entityId"] = p.EntityID
|
||||||
|
body["entityType"] = p.EntityType // 0 = Opportunity on this portal
|
||||||
|
}
|
||||||
|
return c.postJSONObject(ctx, "/api/2.0/crm/invoice", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInvoiceParams are fields for PUT /api/2.0/crm/invoice/{id}.
|
||||||
|
// Loads the current invoice and merges non-zero / set fields.
|
||||||
|
type UpdateInvoiceParams struct {
|
||||||
|
EntityID int64 // link to opportunity; 0 = leave unchanged
|
||||||
|
EntityType int
|
||||||
|
ContactID int64 // 0 = leave unchanged
|
||||||
|
Description string // invoice notes; empty + DescriptionSet=false keeps existing
|
||||||
|
DescriptionSet bool
|
||||||
|
PurchaseOrder string
|
||||||
|
PurchaseOrderSet bool
|
||||||
|
Terms string
|
||||||
|
TermsSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInvoice PUTs a full invoice body (OnlyOffice requires complete payload).
|
||||||
|
//
|
||||||
|
// Linking an opportunity via EntityID on an existing invoice often returns HTTP 400
|
||||||
|
// on this portal — prefer CreateInvoice with EntityID set. See docs/crm-associations.md.
|
||||||
|
func (c *Client) UpdateInvoice(ctx context.Context, id string, p UpdateInvoiceParams) (map[string]any, error) {
|
||||||
|
inv, err := c.GetInvoice(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contactID := int64(0)
|
||||||
|
if m, ok := inv["contact"].(map[string]any); ok {
|
||||||
|
contactID = flexInt(m["id"])
|
||||||
|
}
|
||||||
|
if p.ContactID != 0 {
|
||||||
|
contactID = p.ContactID
|
||||||
|
}
|
||||||
|
cur := "EUR"
|
||||||
|
if m, ok := inv["currency"].(map[string]any); ok {
|
||||||
|
if a := stringField(m, "abbreviation"); a != "" {
|
||||||
|
cur = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusID := 1
|
||||||
|
if m, ok := inv["status"].(map[string]any); ok {
|
||||||
|
statusID = int(flexInt(m["id"]))
|
||||||
|
}
|
||||||
|
var lines []map[string]any
|
||||||
|
switch raw := inv["invoiceLines"].(type) {
|
||||||
|
case []any:
|
||||||
|
for _, row := range raw {
|
||||||
|
m, ok := row.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, map[string]any{
|
||||||
|
"id": flexInt(m["id"]),
|
||||||
|
"invoiceItemID": flexInt(m["invoiceItemID"]),
|
||||||
|
"invoiceTax1ID": flexInt(m["invoiceTax1ID"]),
|
||||||
|
"invoiceTax2ID": flexInt(m["invoiceTax2ID"]),
|
||||||
|
"description": stringField(m, "description"),
|
||||||
|
"quantity": floatField(m, "quantity"),
|
||||||
|
"price": floatField(m, "price"),
|
||||||
|
"discount": floatField(m, "discount"),
|
||||||
|
"sortOrder": int(flexInt(m["sortOrder"])),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case []map[string]any:
|
||||||
|
for _, m := range raw {
|
||||||
|
lines = append(lines, map[string]any{
|
||||||
|
"id": flexInt(m["id"]),
|
||||||
|
"invoiceItemID": flexInt(m["invoiceItemID"]),
|
||||||
|
"invoiceTax1ID": flexInt(m["invoiceTax1ID"]),
|
||||||
|
"invoiceTax2ID": flexInt(m["invoiceTax2ID"]),
|
||||||
|
"description": stringField(m, "description"),
|
||||||
|
"quantity": floatField(m, "quantity"),
|
||||||
|
"price": floatField(m, "price"),
|
||||||
|
"discount": floatField(m, "discount"),
|
||||||
|
"sortOrder": int(flexInt(m["sortOrder"])),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"id": flexInt(inv["id"]),
|
||||||
|
"number": stringField(inv, "number"),
|
||||||
|
"issueDate": stringField(inv, "issueDate"),
|
||||||
|
"dueDate": stringField(inv, "dueDate"),
|
||||||
|
"contactId": contactID,
|
||||||
|
"language": stringField(inv, "language"),
|
||||||
|
"currency": cur,
|
||||||
|
"exchangeRate": floatField(inv, "exchangeRate"),
|
||||||
|
"purchaseOrderNumber": stringField(inv, "purchaseOrderNumber"),
|
||||||
|
"terms": stringField(inv, "terms"),
|
||||||
|
"description": stringField(inv, "description"),
|
||||||
|
"templateType": int(flexInt(inv["templateType"])),
|
||||||
|
"status": statusID,
|
||||||
|
"invoiceLines": lines,
|
||||||
|
}
|
||||||
|
if p.DescriptionSet {
|
||||||
|
body["description"] = p.Description
|
||||||
|
}
|
||||||
|
if p.PurchaseOrderSet {
|
||||||
|
body["purchaseOrderNumber"] = p.PurchaseOrder
|
||||||
|
}
|
||||||
|
if p.TermsSet {
|
||||||
|
body["terms"] = p.Terms
|
||||||
|
}
|
||||||
|
if p.EntityID != 0 {
|
||||||
|
body["entityId"] = p.EntityID
|
||||||
|
body["entityType"] = p.EntityType
|
||||||
|
} else if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
|
||||||
|
body["entityId"] = flexInt(ent["entityId"])
|
||||||
|
// API returns entityType as string ("opportunity"); create/update want int.
|
||||||
|
switch v := ent["entityType"].(type) {
|
||||||
|
case float64:
|
||||||
|
body["entityType"] = int(v)
|
||||||
|
case int:
|
||||||
|
body["entityType"] = v
|
||||||
|
case string:
|
||||||
|
if strings.EqualFold(v, "opportunity") {
|
||||||
|
body["entityType"] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s", url.PathEscape(id)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvoice removes an invoice by id.
|
||||||
|
func (c *Client) DeleteInvoice(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice status ids used by OnlyOffice CRM.
|
||||||
|
const (
|
||||||
|
InvoiceStatusDraft = 1
|
||||||
|
InvoiceStatusBilled = 2
|
||||||
|
InvoiceStatusRejected = 3
|
||||||
|
InvoiceStatusPaid = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetInvoiceStatus sets CRM invoice status for one or more invoice ids
|
||||||
|
// (PUT /api/2.0/crm/invoice/status/{statusId} with invoiceids).
|
||||||
|
// Note: Billed→Draft often does not stick; recreate Draft instead (see docs/crm-associations.md).
|
||||||
|
func (c *Client) SetInvoiceStatus(ctx context.Context, statusID int, invoiceIDs ...int64) (map[string]any, error) {
|
||||||
|
if statusID <= 0 {
|
||||||
|
return nil, fmt.Errorf("status id is required")
|
||||||
|
}
|
||||||
|
if len(invoiceIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one invoice id is required")
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(invoiceIDs))
|
||||||
|
for _, id := range invoiceIDs {
|
||||||
|
ids = append(ids, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("invoiceids", strings.Join(ids, ","))
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/status/%d", statusID), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoicePDFFile returns invoice PDF file metadata (id, title, viewUrl).
|
||||||
|
// Without force, OnlyOffice may return a cached fileID with stale layout.
|
||||||
|
func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) {
|
||||||
|
id := strings.TrimSpace(invoiceID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("InvoicePDFFile: invoice id is required")
|
||||||
|
}
|
||||||
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceRegenerateInvoicePDF clears the cached PDF (Draft touch) then requests a new file.
|
||||||
|
// No-op touch when the invoice is not editable (e.g. Billed) — falls back to GET /pdf.
|
||||||
|
func (c *Client) ForceRegenerateInvoicePDF(ctx context.Context, invoiceID string) (map[string]any, error) {
|
||||||
|
id := strings.TrimSpace(invoiceID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: invoice id is required")
|
||||||
|
}
|
||||||
|
inv, err := c.GetInvoice(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
canEdit := true
|
||||||
|
if v, ok := inv["canEdit"].(bool); ok {
|
||||||
|
canEdit = v
|
||||||
|
}
|
||||||
|
if canEdit {
|
||||||
|
desc := stringField(inv, "description")
|
||||||
|
// Append/remove a trailing space so PUT clears fileID without visible change.
|
||||||
|
touch := desc + " "
|
||||||
|
if strings.HasSuffix(desc, " ") {
|
||||||
|
touch = strings.TrimSuffix(desc, " ")
|
||||||
|
}
|
||||||
|
if _, err := c.UpdateInvoice(ctx, id, UpdateInvoiceParams{
|
||||||
|
Description: touch,
|
||||||
|
DescriptionSet: true,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("ForceRegenerateInvoicePDF: clear cache: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.InvoicePDFFile(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCRMContactFiles lists Documents attached on a CRM contact card (#files).
|
||||||
|
func (c *Client) ListCRMContactFiles(ctx context.Context, contactID string) ([]map[string]any, error) {
|
||||||
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/files.json", url.PathEscape(contactID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOpportunityFiles lists Documents attached on a CRM opportunity.
|
||||||
|
func (c *Client) ListOpportunityFiles(ctx context.Context, opportunityID string) ([]map[string]any, error) {
|
||||||
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/files.json", url.PathEscape(opportunityID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeStaleInvoicePDFs deletes older P-*.pdf copies on the invoice contact (and linked
|
||||||
|
// opportunity) while keeping the invoice's current fileID. Returns deleted file ids.
|
||||||
|
func (c *Client) PurgeStaleInvoicePDFs(ctx context.Context, invoiceID string) ([]int, error) {
|
||||||
|
inv, err := c.GetInvoice(ctx, invoiceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keep := flexInt(inv["fileID"])
|
||||||
|
number := strings.TrimSpace(stringField(inv, "number"))
|
||||||
|
base := number
|
||||||
|
for strings.HasSuffix(base, "b") || strings.HasSuffix(base, "B") {
|
||||||
|
base = base[:len(base)-1]
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
base = "P-"
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[int]struct{}{}
|
||||||
|
var candidates []int
|
||||||
|
addFiles := func(files []map[string]any) {
|
||||||
|
for _, f := range files {
|
||||||
|
title := stringField(f, "title")
|
||||||
|
id := int(flexInt(f["id"]))
|
||||||
|
if id == 0 || id == int(keep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(title, "P-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(title, base) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
candidates = append(candidates, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := inv["contact"].(map[string]any); ok {
|
||||||
|
cid := strconv.FormatInt(flexInt(m["id"]), 10)
|
||||||
|
if cid != "0" {
|
||||||
|
files, err := c.ListCRMContactFiles(ctx, cid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ent, ok := inv["entity"].(map[string]any); ok && ent != nil {
|
||||||
|
if strings.EqualFold(fmt.Sprint(ent["entityType"]), "opportunity") || flexInt(ent["entityType"]) == 0 {
|
||||||
|
oid := strconv.FormatInt(flexInt(ent["entityId"]), 10)
|
||||||
|
if oid != "0" {
|
||||||
|
files, err := c.ListOpportunityFiles(ctx, oid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := c.DeleteFiles(ctx, candidates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// CRM may still list deleted files briefly; also try CRM unlink.
|
||||||
|
for _, fid := range candidates {
|
||||||
|
_, _ = c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/files/%d.json", fid))
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvoiceItems returns catalog invoice items.
|
||||||
|
func (c *Client) ListInvoiceItems(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("count", strconv.Itoa(count))
|
||||||
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/invoiceitem/filter.json?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response []map[string]any `json:"response"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total := env.Total
|
||||||
|
if total == 0 && len(env.Response) > 0 {
|
||||||
|
total = len(env.Response)
|
||||||
|
}
|
||||||
|
return env.Response, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoiceItem creates a reusable catalog line item.
|
||||||
|
func (c *Client) CreateInvoiceItem(ctx context.Context, title, description string, price float64, currency string) (map[string]any, error) {
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = "EUR"
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("title", title)
|
||||||
|
fields.Set("description", description)
|
||||||
|
fields.Set("price", strconv.FormatFloat(price, 'f', 2, 64))
|
||||||
|
fields.Set("currency", currency)
|
||||||
|
fields.Set("stockKeepingUnit", "")
|
||||||
|
fields.Set("trackInventory", "false")
|
||||||
|
fields.Set("quantity", "0")
|
||||||
|
fields.Set("invoiceTax1ID", "0")
|
||||||
|
fields.Set("invoiceTax2ID", "0")
|
||||||
|
return c.postFormObject(ctx, "/api/2.0/crm/invoiceitem", fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvoiceItem removes a catalog invoice item.
|
||||||
|
func (c *Client) DeleteInvoiceItem(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/invoiceitem/%s.json", url.PathEscape(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddContactAddress attaches a postal address to a contact.
|
||||||
|
// category: Home|Postal|Office|Billing|Other|Work (or numeric string).
|
||||||
|
func (c *Client) AddContactAddress(ctx context.Context, contactID, street, city, state, zip, country, category string, isPrimary bool) (map[string]any, error) {
|
||||||
|
if category == "" {
|
||||||
|
category = "Billing"
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("street", street)
|
||||||
|
fields.Set("city", city)
|
||||||
|
fields.Set("state", state)
|
||||||
|
fields.Set("zip", zip)
|
||||||
|
fields.Set("country", country)
|
||||||
|
fields.Set("category", category)
|
||||||
|
fields.Set("isPrimary", strconv.FormatBool(isPrimary))
|
||||||
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/address", url.PathEscape(contactID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompany updates company name and optional about text.
|
||||||
|
func (c *Client) UpdateCompany(ctx context.Context, companyID, name, about string) (map[string]any, error) {
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("companyName", name)
|
||||||
|
if about != "" {
|
||||||
|
fields.Set("about", about)
|
||||||
|
}
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(companyID)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOpportunityParams are optional fields for UpdateOpportunity.
|
||||||
|
// Zero/empty values mean "keep existing" except BidValueSet.
|
||||||
|
type UpdateOpportunityParams struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
StageID int64
|
||||||
|
BidValue float64
|
||||||
|
BidValueSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOpportunity loads a deal and PUTs updated fields.
|
||||||
|
func (c *Client) UpdateOpportunity(ctx context.Context, id string, p UpdateOpportunityParams) (map[string]any, error) {
|
||||||
|
opp, err := c.GetOpportunity(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := p.Title
|
||||||
|
if title == "" {
|
||||||
|
title = stringField(opp, "title")
|
||||||
|
}
|
||||||
|
body := opportunityUpdateBody(opp, title)
|
||||||
|
if p.Description != "" {
|
||||||
|
body["description"] = p.Description
|
||||||
|
}
|
||||||
|
if p.StageID != 0 {
|
||||||
|
body["stageid"] = p.StageID
|
||||||
|
}
|
||||||
|
if p.BidValueSet {
|
||||||
|
body["bidValue"] = p.BidValue
|
||||||
|
}
|
||||||
|
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceContactName extracts displayName from nested contact on an invoice row.
|
||||||
|
func InvoiceContactName(inv map[string]any) string {
|
||||||
|
if m, ok := inv["contact"].(map[string]any); ok {
|
||||||
|
return stringField(m, "displayName")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceStatusTitle extracts status.title from an invoice row.
|
||||||
|
func InvoiceStatusTitle(inv map[string]any) string {
|
||||||
|
if m, ok := inv["status"].(map[string]any); ok {
|
||||||
|
return stringField(m, "title")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlattenInvoiceRow copies nested fields for table output.
|
||||||
|
func FlattenInvoiceRow(inv map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for k, v := range inv {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
out["contactName"] = InvoiceContactName(inv)
|
||||||
|
out["statusTitle"] = InvoiceStatusTitle(inv)
|
||||||
|
if cur, ok := inv["currency"].(map[string]any); ok {
|
||||||
|
out["currency"] = stringField(cur, "abbreviation")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInvoiceItemByTitle returns the first catalog item with an exact title match.
|
||||||
|
func FindInvoiceItemByTitle(items []map[string]any, title string) map[string]any {
|
||||||
|
want := strings.TrimSpace(title)
|
||||||
|
for _, it := range items {
|
||||||
|
if strings.TrimSpace(stringField(it, "title")) == want {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -96,11 +97,157 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
|
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
|
||||||
|
// The API response "response" field may be a number or object; success is HTTP 2xx.
|
||||||
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
|
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required")
|
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required")
|
||||||
}
|
}
|
||||||
return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
|
raw, err := c.putJSON(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]any{"ok": true, "ids": ids}
|
||||||
|
if resp, err := responseField(raw, "response"); err == nil {
|
||||||
|
var n json.Number
|
||||||
|
if json.Unmarshal(resp, &n) == nil {
|
||||||
|
out["response"] = n.String()
|
||||||
|
} else {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(resp, &m) == nil {
|
||||||
|
out["response"] = m
|
||||||
|
} else {
|
||||||
|
out["response"] = string(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMailDraftParams creates or updates a draft via PUT /api/2.0/mail/drafts/save.
|
||||||
|
// Id 0 creates a new draft. Body is HTML; the API field name is "body" (not htmlBody).
|
||||||
|
type SaveMailDraftParams struct {
|
||||||
|
ID int64 // 0 = create
|
||||||
|
From string // mailbox address, e.g. eslider@gmail.com
|
||||||
|
To string // comma-separated or single address
|
||||||
|
Cc string
|
||||||
|
Bcc string
|
||||||
|
Subject string
|
||||||
|
Body string // HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMailDraft saves a draft message. Returns the saved message map (incl. id).
|
||||||
|
func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[string]any, error) {
|
||||||
|
if strings.TrimSpace(p.To) == "" {
|
||||||
|
return nil, fmt.Errorf("SaveMailDraft: to is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.From) == "" {
|
||||||
|
from, err := c.defaultMailFrom(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.From = from
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"id": p.ID,
|
||||||
|
"from": p.From,
|
||||||
|
"to": p.To,
|
||||||
|
"cc": p.Cc,
|
||||||
|
"bcc": p.Bcc,
|
||||||
|
"subject": p.Subject,
|
||||||
|
"body": p.Body,
|
||||||
|
}
|
||||||
|
return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
|
||||||
|
accounts, err := c.ListMailAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var fallback string
|
||||||
|
for _, a := range accounts {
|
||||||
|
email := strings.TrimSpace(stringFromMap(a, "email"))
|
||||||
|
if email == "" || email == "<nil>" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if boolField(a, "enabled") || boolField(a, "isDefault") {
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
return "", fmt.Errorf("SaveMailDraft: from is required (no mailbox on account)")
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachMailDocument attaches an OnlyOffice Files document to a mail message/draft.
|
||||||
|
// fileID is the Documents/CRM file id (e.g. from InvoicePDFFile).
|
||||||
|
func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileID int64) (map[string]any, error) {
|
||||||
|
id := strings.TrimSpace(messageID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("AttachMailDocument: message id is required")
|
||||||
|
}
|
||||||
|
if fileID <= 0 {
|
||||||
|
return nil, fmt.Errorf("AttachMailDocument: file id is required")
|
||||||
|
}
|
||||||
|
fields := url.Values{}
|
||||||
|
fields.Set("fileId", strconv.FormatInt(fileID, 10))
|
||||||
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts.
|
||||||
|
// If s already looks like HTML, it is returned unchanged.
|
||||||
|
func PlainTextToMailHTML(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "<") && strings.Contains(s, ">") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, "\n\n")
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("<p>")
|
||||||
|
b.WriteString(strings.ReplaceAll(p, "\n", "<br/>"))
|
||||||
|
b.WriteString("</p>\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailHTMLWithBlankParagraphs inserts empty paragraphs between blocks
|
||||||
|
// (German Geschäftsbrief-style vertical spacing in OnlyOffice Mail).
|
||||||
|
func MailHTMLWithBlankParagraphs(html string) string {
|
||||||
|
html = strings.TrimSpace(html)
|
||||||
|
if html == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(html, "</p>\n<p>", "</p>\n<p> </p>\n<p>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int64FromMap coerces OnlyOffice numeric id fields (float64/int/string) to int64.
|
||||||
|
func Int64FromMap(m map[string]any, key string) int64 {
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(v)
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
n, _ := strconv.ParseInt(fmt.Sprint(m[key]), 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
|
// ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
|
||||||
|
|||||||
+28
-1
@@ -1,6 +1,9 @@
|
|||||||
package onlyoffice
|
package onlyoffice
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestResolveMailFolder(t *testing.T) {
|
func TestResolveMailFolder(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -70,3 +73,27 @@ func TestMailMessagesAsTableRows(t *testing.T) {
|
|||||||
t.Fatalf("got %+v", rows[0])
|
t.Fatalf("got %+v", rows[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlainTextToMailHTML(t *testing.T) {
|
||||||
|
got := PlainTextToMailHTML("Hello\n\nWorld\nline2")
|
||||||
|
if !strings.Contains(got, "<p>Hello</p>") || !strings.Contains(got, "World<br/>line2") {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
html := "<p>Already</p>"
|
||||||
|
if PlainTextToMailHTML(html) != html {
|
||||||
|
t.Fatalf("html passthrough failed")
|
||||||
|
}
|
||||||
|
spaced := MailHTMLWithBlankParagraphs("<p>A</p>\n<p>B</p>")
|
||||||
|
if spaced != "<p>A</p>\n<p> </p>\n<p>B</p>" {
|
||||||
|
t.Fatalf("spaced: %q", spaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInt64FromMap(t *testing.T) {
|
||||||
|
if Int64FromMap(map[string]any{"id": float64(99)}, "id") != 99 {
|
||||||
|
t.Fatal("float64")
|
||||||
|
}
|
||||||
|
if Int64FromMap(map[string]any{"id": "42"}, "id") != 42 {
|
||||||
|
t.Fatal("string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+61
-6
@@ -134,6 +134,51 @@ func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[stri
|
|||||||
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMilestoneRequest is the payload for CreateMilestone.
|
||||||
|
type NewMilestoneRequest struct {
|
||||||
|
ProjectID int `json:"-"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Deadline Time `json:"deadline"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
IsKey bool `json:"isKey"`
|
||||||
|
IsNotify bool `json:"isNotify"`
|
||||||
|
Responsible string `json:"responsible,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMilestone creates a project milestone (Gantt row).
|
||||||
|
// POST /api/2.0/project/{id}/milestone
|
||||||
|
func (c *Client) CreateMilestone(req NewMilestoneRequest) (*Milestone, error) {
|
||||||
|
if req.Responsible == "" {
|
||||||
|
uid, err := c.SelfUserID(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CreateMilestone: self: %w", err)
|
||||||
|
}
|
||||||
|
req.Responsible = uid
|
||||||
|
}
|
||||||
|
ms := new(Milestone)
|
||||||
|
err := c.Query(Request{
|
||||||
|
Uri: fmt.Sprintf("/api/2.0/project/%d/milestone", req.ProjectID),
|
||||||
|
Method: "POST",
|
||||||
|
Body: req,
|
||||||
|
}, &struct {
|
||||||
|
MetaResponse `json:",inline"`
|
||||||
|
Response *Milestone `json:"response"`
|
||||||
|
}{Response: ms})
|
||||||
|
return ms, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMilestone removes a milestone by id.
|
||||||
|
// DELETE /api/2.0/project/milestone/{id}
|
||||||
|
func (c *Client) DeleteMilestone(id int64) error {
|
||||||
|
return c.Query(Request{
|
||||||
|
Uri: fmt.Sprintf("/api/2.0/project/milestone/%d", id),
|
||||||
|
Method: "DELETE",
|
||||||
|
}, &struct {
|
||||||
|
MetaResponse `json:",inline"`
|
||||||
|
Response *Milestone `json:"response"`
|
||||||
|
}{})
|
||||||
|
}
|
||||||
|
|
||||||
// GetProjectMilestones returns milestones for the given project.
|
// GetProjectMilestones returns milestones for the given project.
|
||||||
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
|
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
|
||||||
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
|
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
|
||||||
@@ -193,16 +238,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