diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..deaa88c --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself +# does not load .env files — only its CLI does, via godotenv). +# +# Primary names: +ONLYOFFICE_URL=https://office.example.com +ONLYOFFICE_USER= +ONLYOFFICE_PASS= + +# Accepted aliases (only one of each pair is needed): +# ONLYOFFICE_HOST= +# ONLYOFFICE_NAME= +# ONLYOFFICE_PASSWORD= + +# Optional defaults for calendar/project helpers: +ONLYOFFICE_CALENDAR_ID=1 +ONLYOFFICE_PROJECT_ID=33 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7cc644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Local environment +.env + +# Go workspace (for local multi-module dev; see inventar ASR-0008) +go.work +go.work.sum + +# Build artifacts (compiled top-level binary only; do NOT match cmd/oo-cli source tree) +/oo-cli +/bin/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b3f7629 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS — go-onlyoffice + +Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo-cli` command. + +## Topology + +- **Library (root package `onlyoffice`)** — `onlyoffice.go`, `httpx.go`, `calendar.go`, `crm.go`, `tasks_extra.go`, `files.go`. Pure stdlib + `google/go-querystring`; no UI, no dotenv. +- **CLI (`cmd/oo-cli` + `internal/cli`)** — Cobra wrapper that loads `.env` via `godotenv` at startup. CLI-only deps (`spf13/cobra`, `joho/godotenv`) must stay out of the library surface. +- **Applications sync (`internal/applications`)** — README/CRM bridge, consumes the library. + +## Rules + +- Library must never call `godotenv.Load()` — the CLI does that. +- New endpoints go into the library first; CLI commands are thin wrappers. +- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only. +- Prefer httptest-based unit tests (see `httpx_test.go`); integration tests (`client_test.go`) auto-skip without `ONLYOFFICE_URL`. +- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`. + +## Related + +- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions). +- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library. +- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e22f188 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.3.0] - 2026-04-24 + +### Added + +- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`. +- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`, + `CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals + (`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`, + `AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases + (`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks + (`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and + history notes (`AddHistoryNote`). +- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`, + `GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`. +- File upload: `UploadOpportunityFile` (multipart). +- `SelfUserID` cached lookup of `people/@self`. +- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional + calendar/project fallbacks. +- Alias env vars accepted by `GetEnvironmentCredentials`: + `ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`. +- Public `Authenticate()` that primes the token eagerly. +- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`, + `cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`, + `task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`, + `crm-add-deal`, `crm-cases`, `applications-sync`. +- httptest-based unit tests for form / multipart / CRM helpers. + +### Changed + +- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`, + `noteCatID`); the public API is unchanged and remains backwards compatible. + +## [0.2.0] - earlier + +- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows). + +## [0.1.0] - earlier + +- Initial release: OnlyOffice Project Management API client. diff --git a/README.md b/README.md index edb50f3..6feb2a9 100644 --- a/README.md +++ b/README.md @@ -510,19 +510,63 @@ type Task struct { | `Time` | `time.Time` wrapper with OnlyOffice JSON format | | `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description | +### Calendar, CRM, Subtasks, File upload (v0.2+) + +Since v0.2 the library also exposes OnlyOffice Workspace surfaces beyond +Projects: Calendar events, CRM (Contacts, Companies, Opportunities, Cases, +Tasks, History notes) and opportunity file uploads. These helpers return +untyped `map[string]any` for flexibility; callers that need typed structs +should use the typed Project/Task API above. + +```go +client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) +client.SetDefaults(onlyoffice.GetEnvironmentDefaults()) // optional +ctx := context.Background() + +// Calendar +events, _ := client.ListEvents(ctx, "2025-01-01", "2025-12-31") +client.AddEvent(ctx, "", "Interview", "2025-06-10T10:00:00Z", "2025-06-10T11:00:00Z", "", false) + +// CRM +deals, total, _ := client.ListOpportunities(ctx, 50, 0) +company, _ := client.FindCompany(ctx, "ACME") + +// Subtasks (form-encoded) +client.AddSubtask(ctx, "4242", "Prepare CV") +``` + +### oo-cli (bundled command) + +A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo-cli`: + +```bash +go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest +oo-cli cal-events +oo-cli task-list --all --verbose +oo-cli subtask-add 4242 "Prepare CV" +oo-cli applications-sync --path ./applications/2026 --apply +``` + +The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library +itself never loads dotfiles). See [`cmd/oo-cli/README.md`](cmd/oo-cli/) for +the command reference. + ## Environment Variables | Variable | Description | |---|---| -| `ONLYOFFICE_URL` | OnlyOffice instance URL | -| `ONLYOFFICE_USER` | Login email or username | -| `ONLYOFFICE_PASS` | Password | +| `ONLYOFFICE_URL` (or `ONLYOFFICE_HOST`) | OnlyOffice instance URL | +| `ONLYOFFICE_USER` (or `ONLYOFFICE_NAME`) | Login email or username | +| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password | +| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) | +| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | ## Examples | Example | Description | |---|---| | [basic](examples/basic/) | List projects and users | +| [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules | ## Related Libraries diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..0c62bfd --- /dev/null +++ b/calendar.go @@ -0,0 +1,78 @@ +package onlyoffice + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD). +// Empty start/end fall back to a wide default window (2020-01-01..2030-12-31), +// matching the Python reference implementation. +func (c *Client) ListCalendars(ctx context.Context, start, end string) ([]map[string]any, error) { + if start == "" { + start = "2020-01-01" + } + if end == "" { + end = "2030-12-31" + } + path := fmt.Sprintf("/api/2.0/calendar/calendars/%s/%s.json", url.PathEscape(start), url.PathEscape(end)) + return c.ResponseArray(ctx, path) +} + +// ListEvents is an alias for ListCalendars; retained for the Python-port CLI. +func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[string]any, error) { + return c.ListCalendars(ctx, start, end) +} + +// AddEvent creates a simple one-shot calendar event. When calendarID is empty +// the configured default (SetDefaults or GetEnvironmentDefaults) is used. +// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601). +func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) { + if calendarID == "" { + calendarID = c.defaults.CalendarID + } + if calendarID == "" { + return nil, fmt.Errorf("AddEvent: calendarID is required (pass explicitly or set via SetDefaults)") + } + fields := url.Values{} + fields.Set("name", title) + fields.Set("description", description) + fields.Set("startDate", start) + fields.Set("endDate", end) + fields.Set("repeatType", "") + fields.Set("alertType", "0") + fields.Set("isAllDayLong", fmt.Sprintf("%t", allDay)) + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var arr []map[string]any + if err := json.Unmarshal(resp, &arr); err != nil { + return nil, err + } + if len(arr) == 0 { + return nil, fmt.Errorf("AddEvent: empty response") + } + return arr[0], nil +} + +// DeleteEvent removes a single calendar event by its event ID. +func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} diff --git a/cmd/oo-cli/README.md b/cmd/oo-cli/README.md new file mode 100644 index 0000000..6b54ad6 --- /dev/null +++ b/cmd/oo-cli/README.md @@ -0,0 +1,47 @@ +# oo-cli + +Cobra CLI wrapper around [`github.com/eslider/go-onlyoffice`](../..). Replaces the Python scripts that used to live under `eSlider/cv/bin/office`. + +## Install + +```bash +go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest +``` + +Ensure `$(go env GOPATH)/bin` is on `PATH`. + +## Config + +Copy [`../../.env.example`](../../.env.example) to `.env` in the working directory: + +| Variable | Alias | Default | +|---|---|---| +| `ONLYOFFICE_URL` | `ONLYOFFICE_HOST` | — | +| `ONLYOFFICE_USER` | `ONLYOFFICE_NAME` | — | +| `ONLYOFFICE_PASS` | `ONLYOFFICE_PASSWORD` | — | +| `ONLYOFFICE_CALENDAR_ID` | — | `1` | +| `ONLYOFFICE_PROJECT_ID` | `ONLYOFFICE_CALENDAR_PROJECT_ID` | `33` | + +## Commands + +| Python (legacy) | Go CLI | +|---|---| +| `list-calendars.py` | `oo-cli cal-list` | +| `list-events.py` | `oo-cli cal-events [--start] [--end]` | +| `add-event.py` | `oo-cli cal-add TITLE START END [--calendar] [--description] [--all-day]` | +| `delete-event.py` | `oo-cli cal-delete ID [ID...]` | +| `list-tasks.py` | `oo-cli task-list [--project] [--status] [--all] [--verbose]` | +| `add-task.py` | `oo-cli task-add TITLE [--project] [--description] [--deadline] [--priority]` | +| `add-subtask.py` | `oo-cli subtask-add PARENT TITLE [TITLE...]` | +| `update-task.py` | `oo-cli task-update ID open\|closed` or `--delete` | +| `list-contacts.py` | `oo-cli crm-contacts [--companies\|--persons] [--search]` | +| `add-contact.py` | `oo-cli crm-add-contact --company ...` or `--person-first` / `--person-last` | +| `list-deals.py` | `oo-cli crm-deals` / `oo-cli crm-deals --stages` | +| `add-deal.py` | `oo-cli crm-add-deal TITLE [--stage] [--bid] [--contact]` | +| `list-cases.py` | `oo-cli crm-cases` | +| `sync-applications.py` | `oo-cli applications-sync --path .../applications/2026 [--apply]` | + +## Notes + +- The CLI loads `.env` via `godotenv`; the library itself does not. +- `applications-sync` defaults to dry-run; pass `--apply` to write to CRM. diff --git a/cmd/oo-cli/main.go b/cmd/oo-cli/main.go new file mode 100644 index 0000000..9325f35 --- /dev/null +++ b/cmd/oo-cli/main.go @@ -0,0 +1,20 @@ +// Command oo-cli is a thin CLI wrapper over the github.com/eslider/go-onlyoffice +// library. It exposes calendar, CRM, tasks, subtasks, and application-sync +// commands. Build & install: +// +// go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest +package main + +import ( + "fmt" + "os" + + "github.com/eslider/go-onlyoffice/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/crm.go b/crm.go new file mode 100644 index 0000000..340363e --- /dev/null +++ b/crm.go @@ -0,0 +1,501 @@ +package onlyoffice + +// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes. +// These expose untyped maps for flexibility — they are primarily consumed by +// cmd/oo-cli and the applications-sync workflow. + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" +) + +// ListContacts returns a page of CRM contacts and the total count. +func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) { + q := url.Values{} + q.Set("count", fmt.Sprintf("%d", count)) + q.Set("startIndex", fmt.Sprintf("%d", startIndex)) + if search != "" { + q.Set("filterValue", search) + } + 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 +} + +// GetContact returns a single contact by id. +func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) { + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// FindCompany searches for a company contact with an exact (case-insensitive) +// displayName match. Returns nil when not found. +func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) { + items, _, err := c.ListContacts(ctx, 50, 0, name) + if err != nil { + return nil, err + } + needle := strings.ToLower(strings.TrimSpace(name)) + for _, co := range items { + if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle { + return co, nil + } + } + return nil, nil +} + +// FindPerson searches for a person by first+last (case-insensitive). +func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) { + items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last) + if err != nil { + return nil, err + } + for _, p := range items { + if isCompany(p) { + continue + } + fn := strings.ToLower(fmt.Sprint(p["firstName"])) + ln := strings.ToLower(fmt.Sprint(p["lastName"])) + if fn == strings.ToLower(first) && ln == strings.ToLower(last) { + return p, nil + } + } + return nil, nil +} + +func isCompany(m map[string]any) bool { + v, ok := m["isCompany"].(bool) + return ok && v +} + +// CreateCompany creates a company contact with the given name. +func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) { + fields := url.Values{} + fields.Set("companyName", name) + raw, err := c.postForm(ctx, "/api/2.0/crm/contact/company.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// CreatePerson creates a person contact; companyID == 0 means unlinked. +func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID int, jobTitle, about string) (map[string]any, error) { + fields := url.Values{} + fields.Set("firstName", first) + fields.Set("lastName", last) + if companyID != 0 { + fields.Set("companyId", strconv.Itoa(companyID)) + } + if jobTitle != "" { + fields.Set("jobTitle", jobTitle) + } + if about != "" { + fields.Set("about", about) + } + raw, err := c.postForm(ctx, "/api/2.0/crm/contact/person.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// AddContactInfo attaches an email/website/phone/etc. to a contact. +func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) { + if category == "" { + category = "Work" + } + fields := url.Values{} + fields.Set("infoType", infoType) + fields.Set("data", dataValue) + fields.Set("category", category) + fields.Set("isPrimary", fmt.Sprintf("%t", isPrimary)) + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// DeleteContact removes a CRM contact by id. +func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// ListOpportunities returns a page of deals/opportunities and the total count. +func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { + q := url.Values{} + q.Set("count", fmt.Sprintf("%d", count)) + q.Set("startIndex", fmt.Sprintf("%d", startIndex)) + raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/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 + } + return env.Response, env.Total, nil +} + +// GetOpportunity returns a single opportunity (deal) by id. +func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) { + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateOpportunity creates a new deal. An empty responsibleID falls back to +// the authenticated user's id, and bidCurrency defaults to "EUR". +func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID int, responsibleID, bidCurrency, description string, bidValue float64) (map[string]any, error) { + if responsibleID == "" { + s, err := c.SelfUserID(ctx) + if err != nil { + return nil, err + } + responsibleID = s + } + if bidCurrency == "" { + bidCurrency = "EUR" + } + fields := url.Values{} + fields.Set("title", title) + fields.Set("stageId", strconv.Itoa(stageID)) + fields.Set("responsibleId", responsibleID) + fields.Set("bidCurrencyAbbr", bidCurrency) + if bidValue != 0 { + fields.Set("bidValue", fmt.Sprintf("%g", bidValue)) + } + if description != "" { + fields.Set("description", description) + } + raw, err := c.postForm(ctx, "/api/2.0/crm/opportunity.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// AddOpportunityMember links a contact to an opportunity. +func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) { + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)), url.Values{}) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// ListDealStages returns the configured opportunity stages. +func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) { + return c.ResponseArray(ctx, "/api/2.0/crm/opportunity/stage.json") +} + +// DeleteOpportunity removes a deal by id. +func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// ListCases returns a page of CRM cases and the total count. +func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { + q := url.Values{} + q.Set("count", fmt.Sprintf("%d", count)) + q.Set("startIndex", fmt.Sprintf("%d", startIndex)) + raw, err := c.getJSON(ctx, "/api/2.0/crm/case/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 + } + return env.Response, env.Total, nil +} + +// CreateCase creates a new CRM case. +func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) { + fields := url.Values{} + fields.Set("title", title) + raw, err := c.postForm(ctx, "/api/2.0/crm/case.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// AddCaseMember links a contact to a CRM case. +func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) { + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)), url.Values{}) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// DeleteCase removes a case by id. +func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// ListCRMTasks returns a page of CRM tasks (separate from Project tasks). +func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { + q := url.Values{} + q.Set("count", fmt.Sprintf("%d", count)) + q.Set("startIndex", fmt.Sprintf("%d", startIndex)) + raw, err := c.getJSON(ctx, "/api/2.0/crm/task/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 + } + return env.Response, env.Total, nil +} + +// CreateCRMTask creates a CRM task (reminder) attached to an entity. +func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) { + fields := url.Values{} + fields.Set("title", title) + fields.Set("deadline", deadline) + fields.Set("categoryId", strconv.Itoa(categoryID)) + if contactID != 0 { + fields.Set("contactId", strconv.Itoa(contactID)) + } + if entityType != "" { + fields.Set("entityType", entityType) + } + if entityID != 0 { + fields.Set("entityId", strconv.Itoa(entityID)) + } + if description != "" { + fields.Set("description", description) + } + raw, err := c.postForm(ctx, "/api/2.0/crm/task.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// DeleteCRMTask removes a CRM task by id. +func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// ListTaskCategories returns CRM task categories. +func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) { + return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json") +} + +// AddHistoryNote attaches a history note to a CRM entity. When categoryID is 0, +// the cached "note" category id is looked up from /api/2.0/crm/history/category.json. +func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID int, content string, categoryID int) (map[string]any, error) { + if categoryID == 0 { + id, err := c.historyNoteCategoryID(ctx) + if err != nil { + return nil, err + } + categoryID = id + } + fields := url.Values{} + fields.Set("entityType", entityType) + fields.Set("entityId", strconv.Itoa(entityID)) + fields.Set("content", content) + fields.Set("categoryId", strconv.Itoa(categoryID)) + raw, err := c.postForm(ctx, "/api/2.0/crm/history.json", fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) { + if c.noteCatID != 0 { + return c.noteCatID, nil + } + list, err := c.ResponseArray(ctx, "/api/2.0/crm/history/category.json") + if err != nil { + return 0, err + } + for _, row := range list { + if strings.EqualFold(fmt.Sprint(row["title"]), "note") { + c.noteCatID = int(flexInt(row["id"])) + return c.noteCatID, nil + } + } + if len(list) > 0 { + c.noteCatID = int(flexInt(list[0]["id"])) + return c.noteCatID, nil + } + return 0, nil +} + +// flexInt coerces OnlyOffice numeric fields that JSON unmarshal may surface as +// float64, int, json.Number, or string into a plain int64. +func flexInt(v any) int64 { + switch x := v.(type) { + case float64: + return int64(x) + case int: + return int64(x) + case int64: + return x + case json.Number: + n, _ := x.Int64() + return n + default: + n, _ := strconv.ParseInt(fmt.Sprint(x), 10, 64) + return n + } +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..bdee420 --- /dev/null +++ b/files.go @@ -0,0 +1,27 @@ +package onlyoffice + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// UploadOpportunityFile uploads a single file to a CRM opportunity. +// Returns the decoded "response" object from OnlyOffice. +func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) { + path := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID)) + raw, err := c.uploadMultipart(ctx, path, "file", filePath) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/go.mod b/go.mod index 560cf6d..682db56 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,10 @@ go 1.22.2 require ( github.com/google/go-querystring v1.2.0 github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index 42afc94..f1f9b54 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,16 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/httpx.go b/httpx.go new file mode 100644 index 0000000..ccd5307 --- /dev/null +++ b/httpx.go @@ -0,0 +1,255 @@ +package onlyoffice + +// Lightweight JSON/form/multipart helpers complementing the typed Query() +// abstraction in onlyoffice.go. These are used by the Calendar, CRM, and +// subtask helpers that talk to non-JSON endpoints (form-encoded or multipart). + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// ensureToken refreshes the authentication token when missing or expired. +// Mirrors the logic inline in Query() but is safe to call from helpers that +// bypass the typed Request abstraction. +func (c *Client) ensureToken() error { + if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) { + return nil + } + tok, err := c.Auth(c.credentials) + if err != nil { + return err + } + c.token = tok + return nil +} + +// authHeader returns the value for the Authorization header, ensuring a token. +func (c *Client) authHeader() (string, error) { + if err := c.ensureToken(); err != nil { + return "", err + } + return c.token.Value, nil +} + +// Authenticate validates credentials and primes the token. Library users may +// call this eagerly to surface auth errors at startup; otherwise the token is +// fetched lazily on the first request. +func (c *Client) Authenticate() error { return c.ensureToken() } + +// baseURL returns the configured base URL without trailing slash. +func (c *Client) baseURL() string { + return strings.TrimRight(c.credentials.Url, "/") +} + +// truncate crops s to n runes, appending "..." when truncated. Used in error +// messages to keep OnlyOffice HTML payloads readable. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// responseField extracts a top-level JSON key (for example "response") from a +// raw OnlyOffice envelope. Returns an error when the key is missing. +func responseField(raw json.RawMessage, key string) (json.RawMessage, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + v, ok := m[key] + if !ok { + return nil, fmt.Errorf("response missing %q", key) + } + return v, nil +} + +// ResponseArray executes a GET and returns the "response" field as []map. +// Returns (nil, nil) when the field is JSON null. +func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]any, error) { + raw, err := c.getJSON(ctx, path) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + if string(resp) == "null" { + return nil, nil + } + var list []map[string]any + if err := json.Unmarshal(resp, &list); err != nil { + return nil, err + } + return list, nil +} + +// getJSON issues an authenticated GET and returns the raw response body. +func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) { + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + 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("GET %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// postForm issues an authenticated POST with application/x-www-form-urlencoded body. +func (c *Client) postForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) { + return c.formRequest(ctx, http.MethodPost, path, fields) +} + +// putForm issues an authenticated PUT with application/x-www-form-urlencoded body. +func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) { + return c.formRequest(ctx, http.MethodPut, path, fields) +} + +func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) { + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, strings.NewReader(fields.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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("%s form %s: %d %s", method, path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// deleteReq issues an authenticated DELETE. +func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, error) { + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + 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("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// SelfUserID returns the ID of the authenticated user (people/@self), cached. +func (c *Client) SelfUserID(ctx context.Context) (string, error) { + if c.selfID != "" { + return c.selfID, nil + } + raw, err := c.getJSON(ctx, "/api/2.0/people/@self.json") + if err != nil { + return "", err + } + var env struct { + Response struct { + ID string `json:"id"` + } `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return "", err + } + c.selfID = env.Response.ID + return c.selfID, nil +} + +// uploadMultipart posts a single file to path under the given form field name. +func (c *Client) uploadMultipart(ctx context.Context, path, fieldName, filePath string) (json.RawMessage, error) { + auth, err := c.authHeader() + if err != nil { + return nil, err + } + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile(fieldName, filepath.Base(filePath)) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, f); err != nil { + return nil, err + } + if err := mw.Close(); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", mw.FormDataContentType()) + 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("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} diff --git a/httpx_test.go b/httpx_test.go new file mode 100644 index 0000000..1aadb44 --- /dev/null +++ b/httpx_test.go @@ -0,0 +1,194 @@ +package onlyoffice + +// Unit tests for the form/multipart helpers and a few CRM/Calendar/Subtask +// methods. These do not require a real OnlyOffice server — they spin up a +// net/http/httptest.Server that emulates the expected endpoints. + +import ( + "context" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// fakeServer wires a minimal OnlyOffice-like API: it always returns the same +// token on /api/2.0/authentication.json and dispatches remaining requests via +// the caller-supplied handler. +func fakeServer(t *testing.T, h http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"token":"TEST_TOKEN","expires":"2099-01-01T00:00:00Z"}}`) + }) + mux.HandleFunc("/", h) + return httptest.NewServer(mux) +} + +// newTestClient builds a Client pointed at the test server with a primed token +// so helpers skip the auth round-trip when the test does not exercise it. +func newTestClient(srv *httptest.Server) *Client { + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + c.token = &Token{Value: "TEST_TOKEN", Expires: Time(time.Now().Add(time.Hour))} + return c +} + +func TestAddSubtaskFormEncoded(t *testing.T) { + var gotPath, gotAuth, gotCT, gotBody string + srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotCT = r.Header.Get("Content-Type") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"id":99,"title":"child"}}`) + }) + defer srv.Close() + c := newTestClient(srv) + + out, err := c.AddSubtask(context.Background(), "42", "child") + if err != nil { + t.Fatalf("AddSubtask: %v", err) + } + if gotPath != "/api/2.0/project/task/42.json" { + t.Errorf("path = %q", gotPath) + } + if gotAuth != "TEST_TOKEN" { + t.Errorf("auth header = %q", gotAuth) + } + if !strings.HasPrefix(gotCT, "application/x-www-form-urlencoded") { + t.Errorf("content-type = %q", gotCT) + } + if gotBody != "title=child" { + t.Errorf("body = %q", gotBody) + } + if fm, ok := out["title"].(string); !ok || fm != "child" { + t.Errorf("out.title = %v", out["title"]) + } +} + +func TestAddEventUsesDefaultCalendar(t *testing.T) { + var gotPath, gotBody string + srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":[{"id":"e1","name":"hi"}]}`) + }) + defer srv.Close() + c := newTestClient(srv) + c.SetDefaults(Defaults{CalendarID: "7"}) + + ev, err := c.AddEvent(context.Background(), "", "hi", "2025-01-01T10:00:00Z", "2025-01-01T11:00:00Z", "desc", false) + if err != nil { + t.Fatalf("AddEvent: %v", err) + } + if gotPath != "/api/2.0/calendar/7/event.json" { + t.Errorf("path = %q", gotPath) + } + if !strings.Contains(gotBody, "name=hi") || !strings.Contains(gotBody, "isAllDayLong=false") { + t.Errorf("body = %q", gotBody) + } + if ev["id"] != "e1" { + t.Errorf("event id = %v", ev["id"]) + } +} + +func TestAddEventRequiresCalendar(t *testing.T) { + srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server must not be hit without calendar id") + }) + defer srv.Close() + c := newTestClient(srv) + if _, err := c.AddEvent(context.Background(), "", "t", "s", "e", "", false); err == nil { + t.Fatal("expected error when no calendar id and no default") + } +} + +func TestListContactsTotal(t *testing.T) { + srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/2.0/crm/contact/filter.json" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("filterValue") != "acme" { + t.Errorf("filterValue = %q", r.URL.Query().Get("filterValue")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":[{"id":1,"displayName":"ACME"}],"total":7}`) + }) + defer srv.Close() + c := newTestClient(srv) + + items, total, err := c.ListContacts(context.Background(), 50, 0, "acme") + if err != nil { + t.Fatalf("ListContacts: %v", err) + } + if total != 7 { + t.Errorf("total = %d", total) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d", len(items)) + } +} + +func TestUploadOpportunityFileMultipart(t *testing.T) { + tmp := t.TempDir() + "/a.txt" + if err := writeFile(tmp, "hello"); err != nil { + t.Fatal(err) + } + var gotField, gotFilename, gotBody string + srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data") { + t.Errorf("content-type = %q", ct) + } + _, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatal(err) + } + mr := multipart.NewReader(r.Body, params["boundary"]) + part, err := mr.NextPart() + if err != nil { + t.Fatal(err) + } + gotField = part.FormName() + gotFilename = part.FileName() + b, _ := io.ReadAll(part) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"id":1,"title":"a.txt"}}`) + }) + defer srv.Close() + c := newTestClient(srv) + + out, err := c.UploadOpportunityFile(context.Background(), "123", tmp) + if err != nil { + t.Fatalf("UploadOpportunityFile: %v", err) + } + if gotField != "file" || gotFilename != "a.txt" || gotBody != "hello" { + t.Errorf("multipart = field=%q filename=%q body=%q", gotField, gotFilename, gotBody) + } + if out["title"] != "a.txt" { + t.Errorf("out.title = %v", out["title"]) + } +} + +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} + +func TestResponseFieldMissing(t *testing.T) { + _, err := responseField(json.RawMessage(`{"other":1}`), "response") + if err == nil { + t.Fatal("expected error on missing field") + } +} diff --git a/internal/applications/applications.go b/internal/applications/applications.go new file mode 100644 index 0000000..b90a402 --- /dev/null +++ b/internal/applications/applications.go @@ -0,0 +1,428 @@ +// Package applications syncs job application README trees into OnlyOffice CRM +// (ported from cv/bin/office/sync-applications.py). +package applications + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + onlyoffice "github.com/eslider/go-onlyoffice" +) + +var docExt = map[string]bool{".pdf": true, ".docx": true, ".xlsx": true, ".doc": true, ".xls": true} + +type RecruiterInfo struct { + First, Last, JobTitle, Company, Email, Phone, LinkedIn string +} + +type Data struct { + Path string + Folder string + Position string + Company string + Location string + Salary string + SalaryValue float64 + Contract string + Source string + Link string + Recruiter RecruiterInfo + Summary string + Documents []string +} + +func Discover(base string) ([]string, error) { + var out []string + err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "pdfs" { + return fs.SkipDir + } + return nil + } + if filepath.Base(path) != "README.md" { + return nil + } + dir := filepath.Dir(path) + if filepath.Clean(dir) == filepath.Clean(base) { + return nil + } + out = append(out, path) + return nil + }) + return out, err +} + +func ParseReadme(path string) (Data, error) { + b, err := os.ReadFile(path) + if err != nil { + return Data{}, err + } + text := string(b) + app := Data{Path: path} + app.Folder = relFolder(path) + app.Position = extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`) + app.Company = extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`) + if app.Company != "" { + app.Company = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(app.Company, "$1") + app.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(app.Company), "") + } + app.Location = extract(text, `(?i)\*\*Location\*\*:\s*(.+?)(?:\s{2,}|\n)`) + app.Salary = extract(text, `(?i)\*\*(?:Salary|Gehalt|Rate)\*\*:\s*(.+?)(?:\s{2,}|\n)`) + app.SalaryValue = parseSalary(app.Salary) + app.Contract = extract(text, `(?i)\*\*Contract\*\*:\s*(.+?)(?:\s{2,}|\n)`) + app.Link = extract(text, `(?i)\*\*Link\*\*:\s*(.+?)(?:\s{2,}|\n)`) + if m := regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`).FindStringSubmatch(app.Link); len(m) > 2 { + app.Link = m[2] + } + app.Source = extract(text, `(?i)\*\*Source\*\*:\s*(.+?)(?:\s{2,}|\n)`) + app.Recruiter = parseRecruiter(text) + if app.Source == "" { + app.Source = filepath.Base(filepath.Dir(filepath.Dir(path))) + } + if app.Company == "" || strings.EqualFold(app.Company, "undisclosed") || app.Company == "?" || strings.EqualFold(app.Company, "tbd") { + parts := strings.Split(filepath.Base(filepath.Dir(path)), "-") + if len(parts) >= 2 { + app.Company = titleWord(parts[0]) + } + } + app.Summary = buildSummary(app, text) + app.Documents = discoverDocs(filepath.Dir(path)) + return app, nil +} + +func relFolder(readmePath string) string { + // Python: path.parent.relative_to(path.parent.parent.parent.parent) — approximate: last 3 parts + p := filepath.Clean(readmePath) + parts := strings.Split(p, string(filepath.Separator)) + if len(parts) >= 3 { + return filepath.Join(parts[len(parts)-3:]...) + } + return filepath.Dir(readmePath) +} + +func extract(text, pat string) string { + re := regexp.MustCompile(pat) + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +func parseSalary(raw string) float64 { + if raw == "" { + return 0 + } + cleaned := strings.ReplaceAll(strings.ReplaceAll(raw, ",", ""), ".", "") + if m := regexp.MustCompile(`(\d{4,6})`).FindAllStringSubmatch(cleaned, -1); len(m) > 0 { + var maxv int + for _, x := range m { + n, _ := strconv.Atoi(x[1]) + if n > maxv { + maxv = n + } + } + return float64(maxv) + } + if m := regexp.MustCompile(`(?i)(\d{2,4})\s*/\s*day`).FindStringSubmatch(raw); len(m) > 1 { + n, _ := strconv.ParseFloat(m[1], 64) + return n * 220 + } + return 0 +} + +func parseRecruiter(text string) RecruiterInfo { + var r RecruiterInfo + raw := extract(text, `(?i)\*\*(?:Recruiter|Consultant)\*\*:\s*(.+?)(?:\s{2,}|\n)`) + if raw == "" { + return r + } + if m := regexp.MustCompile(`<([^>]+@[^>]+)>`).FindStringSubmatch(raw); len(m) > 1 { + r.Email = strings.TrimSpace(m[1]) + if i := strings.Index(raw, "<"); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + raw = strings.TrimRight(raw, " —–-") + } + if m := regexp.MustCompile(`(?i)\s+@\s+(.+)$`).FindStringSubmatch(raw); len(m) > 1 { + r.Company = strings.TrimSpace(m[1]) + if i := strings.Index(raw, m[0]); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + } + if m := regexp.MustCompile(`,?\s*\[([^\]]+)\]\([^)]+\)`).FindStringSubmatch(raw); len(m) > 1 { + r.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(m[1]), "") + if i := strings.Index(raw, m[0]); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + } + parts := strings.SplitN(raw, ",", 2) + namePart := strings.TrimSpace(parts[0]) + if len(parts) > 1 { + r.JobTitle = strings.TrimSpace(parts[1]) + } + toks := strings.Fields(namePart) + if len(toks) >= 2 { + r.First = toks[0] + r.Last = strings.Join(toks[1:], " ") + } else if len(toks) == 1 { + r.First = toks[0] + } + if m := regexp.MustCompile(`(?i)\*\*?(?:Telefon|Phone)\*\*?:\s*([\d+\-\s]+)`).FindStringSubmatch(text); len(m) > 1 { + r.Phone = strings.TrimSpace(m[1]) + } + if m := regexp.MustCompile(`(?i)\*\*LinkedIn\*\*:\s*(https://[^\s]+)`).FindStringSubmatch(text); len(m) > 1 { + r.LinkedIn = strings.TrimSpace(m[1]) + } + return r +} + +func buildSummary(app Data, text string) string { + var lines []string + lines = append(lines, "Position: "+app.Position) + lines = append(lines, "Company: "+app.Company) + if app.Location != "" { + lines = append(lines, "Location: "+app.Location) + } + if app.Salary != "" { + lines = append(lines, "Salary: "+app.Salary) + } + if app.Contract != "" { + lines = append(lines, "Contract: "+app.Contract) + } + if app.Source != "" { + lines = append(lines, "Source: "+app.Source) + } + if app.Link != "" { + lines = append(lines, "Link: "+app.Link) + } + lines = append(lines, "Folder: "+app.Folder) + if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 { + lines = append(lines, "", "Status:", strings.TrimSpace(m[1])) + } + if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?=\n## |\z)`).FindStringSubmatch(text); len(m) > 1 { + ft := strings.TrimSpace(m[1]) + if len(ft) > 500 { + ft = ft[:500] + "..." + } + lines = append(lines, "", "Fit Assessment:", ft) + } + return strings.Join(lines, "\n") +} + +func discoverDocs(dir string) []string { + var docs []string + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if docExt[ext] { + docs = append(docs, filepath.Join(dir, e.Name())) + } + } + return docs +} + +type Stats struct { + Companies, Persons, Deals, Tasks, Docs, Notes int +} + +// Sync mirrors Python sync_to_crm. +func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, verbose bool) Stats { + var st Stats + deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05") + const stageInitial = 1 + for _, app := range apps { + dealTitle := app.Position + if app.Company != "" { + dealTitle = app.Position + " @ " + app.Company + } + fmt.Println(strings.Repeat("─", 60)) + fmt.Println(" ", app.Folder) + fmt.Println(" ", dealTitle) + if verbose { + // minimal verbose + if app.Salary != "" { + fmt.Printf(" Salary: %s (parsed: %.0f)\n", app.Salary, app.SalaryValue) + } + } + action := "would create" + if !dryRun { + action = "creating" + } + companyID := 0 + if app.Company != "" { + if !dryRun { + if ex, _ := client.FindCompany(ctx, app.Company); ex != nil { + companyID = int(flexID(ex["id"])) + fmt.Printf(" [company] found: [%d] %s\n", companyID, app.Company) + } else { + co, err := client.CreateCompany(ctx, app.Company) + if err != nil { + fmt.Printf(" [company] ERR: %v\n", err) + } else { + companyID = int(flexID(co["id"])) + fmt.Printf(" [company] created: [%d] %s\n", companyID, app.Company) + if app.Link != "" { + _, _ = client.AddContactInfo(ctx, strconv.Itoa(companyID), "Website", app.Link, "Work", false) + } + } + } + } else { + fmt.Printf(" [company] %s: %s\n", action, app.Company) + } + st.Companies++ + } + personID := 0 + r := app.Recruiter + if r.First != "" && r.Last != "" { + if !dryRun { + if ex, _ := client.FindPerson(ctx, r.First, r.Last); ex != nil { + personID = int(flexID(ex["id"])) + fmt.Printf(" [person] found: [%d] %s %s\n", personID, r.First, r.Last) + } else { + p, err := client.CreatePerson(ctx, r.First, r.Last, companyID, r.JobTitle, "") + if err != nil { + fmt.Printf(" [person] ERR: %v\n", err) + } else { + personID = int(flexID(p["id"])) + fmt.Printf(" [person] created: [%d] %s %s\n", personID, r.First, r.Last) + if r.Email != "" { + _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Email", r.Email, "Work", true) + } + if r.Phone != "" { + _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Phone", r.Phone, "Work", false) + } + if r.LinkedIn != "" { + _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "LinkedIn", r.LinkedIn, "Work", false) + } + } + } + } else { + fmt.Printf(" [person] %s: %s %s\n", action, r.First, r.Last) + } + st.Persons++ + } + var dealID int + if dryRun { + fmt.Printf(" [deal] %s: %s\n", action, dealTitle) + } else { + if ex := findExistingOpp(ctx, client, dealTitle); ex != nil { + dealID = int(flexID(ex["id"])) + fmt.Printf(" [deal] found: [%d] %s\n", dealID, dealTitle) + } else { + d, err := client.CreateOpportunity(ctx, dealTitle, stageInitial, "", "EUR", "", app.SalaryValue) + if err != nil { + fmt.Printf(" [deal] ERR: %v\n", err) + } else { + dealID = int(flexID(d["id"])) + fmt.Printf(" [deal] created: [%d] %s\n", dealID, dealTitle) + } + } + } + st.Deals++ + if !dryRun && dealID != 0 { + if companyID != 0 { + _, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID)) + } + if personID != 0 { + _, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID)) + } + } + if app.Summary != "" { + if dryRun { + fmt.Printf(" [note] %s: application summary\n", action) + } else if dealID != 0 { + if _, err := client.AddHistoryNote(ctx, "opportunity", dealID, app.Summary, 0); err == nil { + fmt.Println(" [note] added application summary") + st.Notes++ + } + } + } + titles := []string{"Write application", "Apply application", "Send CV"} + if dryRun { + for _, t := range titles { + fmt.Printf(" [task] %s: %s\n", action, t) + } + } else if dealID != 0 { + for _, t := range titles { + if _, err := client.CreateCRMTask(ctx, t, deadline, 2, 0, "opportunity", dealID, ""); err == nil { + fmt.Printf(" [task] created: %s\n", t) + st.Tasks++ + } + } + } + for _, doc := range app.Documents { + if dryRun { + fmt.Printf(" [doc] %s: %s\n", action, filepath.Base(doc)) + } else if dealID != 0 { + if _, err := client.UploadOpportunityFile(ctx, strconv.Itoa(dealID), doc); err == nil { + fmt.Printf(" [doc] uploaded: %s\n", filepath.Base(doc)) + st.Docs++ + } + } + } + } + mode := "DRY RUN" + if !dryRun { + mode = "APPLIED" + } + fmt.Println(strings.Repeat("═", 60)) + fmt.Printf(" %s: deals=%d companies=%d persons=%d notes=%d tasks=%d docs=%d\n", + mode, st.Deals, st.Companies, st.Persons, st.Notes, st.Tasks, st.Docs) + return st +} + +func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} { + deals, total, _ := c.ListOpportunities(ctx, 100, 0) + for _, d := range deals { + if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { + return d + } + } + if total > 100 { + deals2, _, _ := c.ListOpportunities(ctx, 100, 100) + for _, d := range deals2 { + if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { + return d + } + } + } + return nil +} + +func titleWord(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +func flexID(v interface{}) float64 { + switch x := v.(type) { + case float64: + return x + case int: + return float64(x) + case json.Number: + f, _ := x.Float64() + return f + default: + f, _ := strconv.ParseFloat(fmt.Sprint(x), 64) + return f + } +} diff --git a/internal/applications/parse_test.go b/internal/applications/parse_test.go new file mode 100644 index 0000000..0a4bed2 --- /dev/null +++ b/internal/applications/parse_test.go @@ -0,0 +1,22 @@ +package applications + +import "testing" + +func TestExtractField(t *testing.T) { + text := "**Position**: Senior Dev\n**Company**: Acme\n" + if g := extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Senior Dev" { + t.Fatalf("position: %q", g) + } + if g := extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Acme" { + t.Fatalf("company: %q", g) + } +} + +func TestParseSalary(t *testing.T) { + if v := parseSalary("€50,000"); v < 40000 { + t.Fatalf("annual: %v", v) + } + if v := parseSalary("400 / day"); v != 400*220 { + t.Fatalf("daily: %v", v) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..3dc3089 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,517 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "time" + + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/eslider/go-onlyoffice/internal/applications" + "github.com/joho/godotenv" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "oo-cli", + Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)", +} + +// Execute runs the root command; the binary in cmd/oo-cli is a thin wrapper. +func Execute() error { + return rootCmd.Execute() +} + +// newOO loads env (incl. .env in CWD) and returns an authenticated client. +// godotenv is a CLI-only concern; the library itself never loads dotfiles. +func newOO() (*onlyoffice.Client, error) { + _ = godotenv.Load() + creds := onlyoffice.GetEnvironmentCredentials() + if creds.Url == "" || creds.User == "" || creds.Password == "" { + return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)") + } + c := onlyoffice.NewClient(creds) + c.SetDefaults(onlyoffice.GetEnvironmentDefaults()) + if err := c.Authenticate(); err != nil { + return nil, err + } + return c, nil +} + +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func init() { + rootCmd.AddCommand(cmdCalList()) + rootCmd.AddCommand(cmdCalEvents()) + rootCmd.AddCommand(cmdCalAdd()) + rootCmd.AddCommand(cmdCalDel()) + rootCmd.AddCommand(cmdTaskList()) + rootCmd.AddCommand(cmdTaskAdd()) + rootCmd.AddCommand(cmdSubtaskAdd()) + rootCmd.AddCommand(cmdTaskUpdate()) + rootCmd.AddCommand(cmdCRMContacts()) + rootCmd.AddCommand(cmdCRMAddContact()) + rootCmd.AddCommand(cmdCRMDeals()) + rootCmd.AddCommand(cmdCRMAddDeal()) + rootCmd.AddCommand(cmdCRMCases()) + rootCmd.AddCommand(cmdAppsSync()) +} + +func cmdCalList() *cobra.Command { + return &cobra.Command{ + Use: "cal-list", + Short: "List calendars (default date span)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + out, err := c.ListCalendars(cmd.Context(), "", "") + if err != nil { + return err + } + printJSON(out) + return nil + }, + } +} + +func cmdCalEvents() *cobra.Command { + var start, end string + cmd := &cobra.Command{ + Use: "cal-events", + Short: "List calendar data for period (default: next 7 days)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + if start == "" || end == "" { + // Match list-events.py default window + start = time.Now().Format("2006-01-02") + end = time.Now().AddDate(0, 0, 7).Format("2006-01-02") + } + out, err := c.ListEvents(cmd.Context(), start, end) + if err != nil { + return err + } + printJSON(out) + return nil + }, + } + cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD") + cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD") + return cmd +} + +func cmdCalAdd() *cobra.Command { + var cal, desc string + var allDay bool + cmd := &cobra.Command{ + Use: "cal-add TITLE START END", + Short: "Add calendar event", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay) + if err != nil { + return err + } + printJSON(ev) + return nil + }, + } + cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)") + cmd.Flags().StringVar(&desc, "description", "", "") + cmd.Flags().BoolVar(&allDay, "all-day", false, "") + return cmd +} + +func cmdCalDel() *cobra.Command { + return &cobra.Command{ + Use: "cal-delete EVENT_ID [EVENT_ID...]", + Short: "Delete calendar events", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + for _, id := range args { + out, err := c.DeleteEvent(cmd.Context(), id) + if err != nil { + return err + } + printJSON(out) + } + return nil + }, + } +} + +func cmdTaskList() *cobra.Command { + var project, status string + var all, verbose bool + cmd := &cobra.Command{ + Use: "task-list", + Short: "List project tasks", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + var tasks []map[string]interface{} + if all { + tasks, err = c.ListAllTasks(cmd.Context(), status) + } else { + tasks, err = c.ListTasks(cmd.Context(), project, status) + } + if err != nil { + return err + } + for _, t := range tasks { + fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"]) + if verbose { + d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"])) + subs, _ := d["subtasks"].([]interface{}) + for _, s := range subs { + sm, _ := s.(map[string]interface{}) + fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"]) + } + } + } + return nil + }, + } + cmd.Flags().StringVarP(&project, "project", "p", "", "project id") + cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed") + cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "") + return cmd +} + +func cmdTaskAdd() *cobra.Command { + var project, desc, deadline string + var prio string + cmd := &cobra.Command{ + Use: "task-add TITLE", + Short: "Add project task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + p := 0 + if prio == "high" { + p = 1 + } + if prio == "low" { + p = -1 + } + out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline) + if err != nil { + return err + } + printJSON(out) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "") + cmd.Flags().StringVar(&desc, "description", "", "") + cmd.Flags().StringVar(&deadline, "deadline", "", "") + cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low") + return cmd +} + +func cmdSubtaskAdd() *cobra.Command { + return &cobra.Command{ + Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]", + Short: "Add subtask(s)", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + parent := args[0] + for _, title := range args[1:] { + out, err := c.AddSubtask(cmd.Context(), parent, title) + if err != nil { + return err + } + printJSON(out) + } + return nil + }, + } +} + +func cmdTaskUpdate() *cobra.Command { + var del bool + cmd := &cobra.Command{ + Use: "task-update TASK_ID open|closed", + Short: "Update task status or delete", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + id := args[0] + if del { + out, err := c.DeleteTask(cmd.Context(), id) + if err != nil { + return err + } + printJSON(out) + return nil + } + if len(args) < 2 { + return fmt.Errorf("need status or --delete") + } + out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1]) + if err != nil { + return err + } + printJSON(out) + return nil + }, + } + cmd.Flags().BoolVar(&del, "delete", false, "delete task") + return cmd +} + +func cmdCRMContacts() *cobra.Command { + var companies, persons bool + var search string + cmd := &cobra.Command{ + Use: "crm-contacts", + Short: "List CRM contacts", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + list, _, err := c.ListContacts(cmd.Context(), 50, 0, search) + if err != nil { + return err + } + for _, row := range list { + isCo, _ := row["isCompany"].(bool) + if companies && !isCo { + continue + } + if persons && isCo { + continue + } + printJSON(row) + } + return nil + }, + } + cmd.Flags().BoolVar(&companies, "companies", false, "") + cmd.Flags().BoolVar(&persons, "persons", false, "") + cmd.Flags().StringVar(&search, "search", "", "") + return cmd +} + +func cmdCRMAddContact() *cobra.Command { + var company, email, website, linkedin string + var personFirst, personLast string + var companyID int + cmd := &cobra.Command{ + Use: "crm-add-contact", + Short: "Add company or person", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + if company != "" { + out, err := c.CreateCompany(cmd.Context(), company) + if err != nil { + return err + } + cid := int(flexIDMap(out["id"])) + if email != "" { + _, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Email", email, "Work", true) + } + if website != "" { + _, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Website", website, "Work", false) + } + printJSON(out) + return nil + } + if personFirst != "" && personLast != "" { + out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "") + if err != nil { + return err + } + pid := fmt.Sprint(int(flexIDMap(out["id"]))) + if email != "" { + _, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true) + } + if linkedin != "" { + _, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false) + } + printJSON(out) + return nil + } + return fmt.Errorf("set --company or --person-first and --person-last") + }, + } + cmd.Flags().StringVar(&company, "company", "", "") + cmd.Flags().StringVar(&personFirst, "person-first", "", "") + cmd.Flags().StringVar(&personLast, "person-last", "", "") + cmd.Flags().IntVar(&companyID, "company-id", 0, "") + cmd.Flags().StringVar(&email, "email", "", "") + cmd.Flags().StringVar(&website, "website", "", "") + cmd.Flags().StringVar(&linkedin, "linkedin", "", "") + return cmd +} + +func flexIDMap(v interface{}) float64 { + switch x := v.(type) { + case float64: + return x + case int: + return float64(x) + default: + f, _ := strconv.ParseFloat(fmt.Sprint(x), 64) + return f + } +} + +func cmdCRMDeals() *cobra.Command { + var stages bool + cmd := &cobra.Command{ + Use: "crm-deals", + Short: "List opportunities / stages", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + if stages { + out, err := c.ListDealStages(cmd.Context()) + if err != nil { + return err + } + printJSON(out) + return nil + } + list, total, err := c.ListOpportunities(cmd.Context(), 100, 0) + if err != nil { + return err + } + fmt.Println("total:", total) + printJSON(list) + return nil + }, + } + cmd.Flags().BoolVar(&stages, "stages", false, "") + return cmd +} + +func cmdCRMAddDeal() *cobra.Command { + var stage int + var bid float64 + var contacts []string + cmd := &cobra.Command{ + Use: "crm-add-deal TITLE", + Short: "Create opportunity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + if stage == 0 { + stage = 1 + } + out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid) + if err != nil { + return err + } + did := fmt.Sprint(int(flexIDMap(out["id"]))) + for _, cid := range contacts { + _, _ = c.AddOpportunityMember(cmd.Context(), did, cid) + } + printJSON(out) + return nil + }, + } + cmd.Flags().IntVar(&stage, "stage", 1, "") + cmd.Flags().Float64Var(&bid, "bid", 0, "") + cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)") + return cmd +} + +func cmdCRMCases() *cobra.Command { + return &cobra.Command{ + Use: "crm-cases", + Short: "List CRM cases", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + list, total, err := c.ListCases(cmd.Context(), 50, 0) + if err != nil { + return err + } + fmt.Println("total:", total) + printJSON(list) + return nil + }, + } +} + +func cmdAppsSync() *cobra.Command { + var apply, verbose bool + var base string + cmd := &cobra.Command{ + Use: "applications-sync", + Short: "Sync applications/*/README.md into CRM", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO() + if err != nil { + return err + } + if base == "" { + return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)") + } + paths, err := applications.Discover(base) + if err != nil { + return err + } + var apps []applications.Data + for _, p := range paths { + a, err := applications.ParseReadme(p) + if err != nil { + return err + } + if a.Position == "" && a.Company == "" { + continue + } + apps = append(apps, a) + } + fmt.Printf("Found %d application(s)\n", len(apps)) + applications.Sync(cmd.Context(), c, apps, !apply, verbose) + return nil + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "write to CRM (default dry-run)") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "") + cmd.Flags().StringVar(&base, "path", "", "applications base directory") + return cmd +} diff --git a/onlyoffice.go b/onlyoffice.go index d381032..eeb476f 100644 --- a/onlyoffice.go +++ b/onlyoffice.go @@ -22,14 +22,61 @@ func NewClient(c Credentials) *Client { } } -// GetEnvironmentCredentials when using environment variables -func GetEnvironmentCredentials() Credentials { - return Credentials{ - Url: os.Getenv("ONLYOFFICE_URL"), - User: os.Getenv("ONLYOFFICE_USER"), - Password: os.Getenv("ONLYOFFICE_PASS"), - } +// Defaults holds optional fallbacks used by package-level helpers when callers +// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults. +type Defaults struct { + CalendarID string + ProjectID string +} +// SetDefaults configures optional identifiers used as fallbacks by methods such +// as AddEvent (when calendarID == "") or AddTask (when projectID == ""). +func (c *Client) SetDefaults(d Defaults) { c.defaults = d } + +// firstNonEmpty returns the first non-empty trimmed value, or "" if none found. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +// GetEnvironmentCredentials reads OnlyOffice credentials from environment. +// +// Primary variables (documented): +// - ONLYOFFICE_URL +// - ONLYOFFICE_USER +// - ONLYOFFICE_PASS +// +// Additional aliases accepted for interoperability with sibling tools: +// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL) +// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER) +// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS) +func GetEnvironmentCredentials() Credentials { + url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST")) + url = strings.TrimRight(url, "/") + return Credentials{ + Url: url, + User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")), + Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")), + } +} + +// GetEnvironmentDefaults reads optional library defaults from environment: +// +// - ONLYOFFICE_CALENDAR_ID (default: "1") +// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33") +func GetEnvironmentDefaults() Defaults { + return Defaults{ + CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"), + ProjectID: firstNonEmpty( + os.Getenv("ONLYOFFICE_PROJECT_ID"), + os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"), + "33", + ), + } } // Credentials of OnlyOffice User @@ -53,6 +100,10 @@ type Client struct { client *http.Client // HTTP client credentials *Credentials // OnlyOffice credentials token *Token // Authentication token + + defaults Defaults // optional fallbacks for calendar/project IDs + selfID string // cached /api/2.0/people/@self id + noteCatID int // cached CRM history category id for "note" } // MetaResponse Response diff --git a/tasks_extra.go b/tasks_extra.go new file mode 100644 index 0000000..f124231 --- /dev/null +++ b/tasks_extra.go @@ -0,0 +1,169 @@ +package onlyoffice + +// Task-related helpers that complement the typed Project/Task API in +// onlyoffice.go. These return untyped maps and use form-encoded endpoints, +// matching the Python cv/bin/office reference and the OnlyOffice web UI. + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// GetProjectByID returns a single project as an untyped map. +// (The typed counterpart GetProject is not provided; callers that want typed +// structs should use Projects + iteration or add their own typed wrappers.) +func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[string]any, error) { + if projectID == "" { + projectID = c.defaults.ProjectID + } + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// ListTasks lists tasks in a single project. When projectID is empty the +// configured default is used. status accepts "open"/"closed" or a numeric code. +func (c *Client) ListTasks(ctx context.Context, projectID, status string) ([]map[string]any, error) { + if projectID == "" { + projectID = c.defaults.ProjectID + } + if projectID == "" { + return nil, fmt.Errorf("ListTasks: projectID is required (pass explicitly or set via SetDefaults)") + } + path := fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)) + if status != "" { + code := map[string]string{"open": "0", "closed": "1"}[status] + if code == "" { + code = status + } + path += "?status=" + url.QueryEscape(code) + } + return c.ResponseArray(ctx, path) +} + +// ListAllTasks lists tasks across projects for the authenticated user. +func (c *Client) ListAllTasks(ctx context.Context, status string) ([]map[string]any, error) { + path := "/api/2.0/project/task/@self.json" + if status != "" { + code := map[string]string{"open": "0", "closed": "1"}[status] + if code == "" { + code = status + } + path += "?status=" + url.QueryEscape(code) + } + return c.ResponseArray(ctx, path) +} + +// GetTaskByID returns a task as an untyped map, including its subtasks. +func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any, error) { + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// AddTask creates a task via the form-encoded endpoint (no milestone/start). +// Prefer the typed CreateProjectTask for new code; AddTask is kept for parity +// with the Python reference tooling. +func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) { + if projectID == "" { + projectID = c.defaults.ProjectID + } + fields := url.Values{} + fields.Set("title", title) + fields.Set("description", description) + fields.Set("priority", fmt.Sprintf("%d", priority)) + if deadline != "" { + fields.Set("deadline", deadline) + } + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// AddSubtask creates a subtask under parentTaskID. +func (c *Client) AddSubtask(ctx context.Context, parentTaskID, title string) (map[string]any, error) { + fields := url.Values{} + fields.Set("title", title) + raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(parentTaskID)), fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil +} + +// UpdateTaskStatus changes task status. status accepts "open"/"closed" (mapped +// to 1/2) or a raw numeric code passed through. +func (c *Client) UpdateTaskStatus(ctx context.Context, taskID, status string) (map[string]any, error) { + code := map[string]string{"open": "1", "closed": "2"}[status] + if code == "" { + code = status + } + fields := url.Values{} + fields.Set("status", code) + raw, err := c.putForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s/status.json", url.PathEscape(taskID)), fields) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +} + +// DeleteTask removes a project task by ID. +func (c *Client) DeleteTask(ctx context.Context, taskID string) (map[string]any, error) { + raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID))) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var out map[string]any + _ = json.Unmarshal(resp, &out) + return out, nil +}