From 1e6d22f38dd7a70d4cf1b69ef0b8749b01fded1a Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 24 Apr 2026 14:55:43 +0100 Subject: [PATCH] feat(cli)!: subject-based command tree (tea-style) + global --output flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: - Flat oo verb-noun layout replaced with subject → verb, mirroring the gitea `tea` CLI. Every subject is one file in cmd/oo/: oo calendar list | events | add | delete oo projects list | get | milestones | create | update | delete oo tasks list | get | create | update | delete | subtask add oo users list | self (alias: oo whoami) oo contacts list | get | delete | info-add oo persons list | create | delete oo companies list | create | delete oo opportunities list | get | create | delete | stages | member-add oo cases list | create | delete | member-add oo crm-tasks list | create | delete | categories oo applications sync - Global `--output/-o table|json` flag on the root command; every list uses a single printTable helper that flattens nested bidCurrency → abbreviation for readability, truncates long cells, and dumps verbatim in JSON mode. Library additions surfaced in the CLI: - oo users / oo whoami via SelfUserID + GetUsers. - oo projects full CRUD via Create/Update/Delete/GetProjectByID/Milestones. - oo crm-tasks and oo cases via ListCRMTasks/CreateCRMTask/DeleteCRMTask/ ListTaskCategories/ListCases/CreateCase/DeleteCase/AddCaseMember. - oo contacts info-add via AddContactInfo. - oo opportunities stages/member-add via ListDealStages/AddOpportunityMember. Internal: - cmd/oo/common.go — rootCmd, newOO (uses AuthenticateContext), printTable, printObject, fmtCell, deref* helpers. - cmd/oo/crm.go deleted; content split into contacts.go, opportunities.go, cases.go, crm_tasks.go for locality. Docs: - README: new subject→verb table + migration note. - AGENTS.md: CLI section rewritten; new rule enforcing subject→verb and printTable/printObject routing. - CHANGELOG: 0.5.0 entry with full old→new mapping. Made-with: Cursor --- AGENTS.md | 8 +- CHANGELOG.md | 58 ++++++++++ README.md | 50 ++++++-- cmd/oo/apps.go | 21 +++- cmd/oo/calendar.go | 63 ++++++---- cmd/oo/cases.go | 111 ++++++++++++++++++ cmd/oo/common.go | 148 ++++++++++++++++++++---- cmd/oo/contacts.go | 248 ++++++++++++++++++++++++++++++++++++++++ cmd/oo/crm.go | 181 ----------------------------- cmd/oo/crm_tasks.go | 123 ++++++++++++++++++++ cmd/oo/main.go | 22 +++- cmd/oo/opportunities.go | 177 ++++++++++++++++++++++++++++ cmd/oo/projects.go | 231 +++++++++++++++++++++++++++++++++++++ cmd/oo/tasks.go | 185 +++++++++++++++++++----------- cmd/oo/users.go | 93 +++++++++++++++ 15 files changed, 1406 insertions(+), 313 deletions(-) create mode 100644 cmd/oo/cases.go create mode 100644 cmd/oo/contacts.go delete mode 100644 cmd/oo/crm.go create mode 100644 cmd/oo/crm_tasks.go create mode 100644 cmd/oo/opportunities.go create mode 100644 cmd/oo/projects.go create mode 100644 cmd/oo/users.go diff --git a/AGENTS.md b/AGENTS.md index b69b47d..3852431 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,11 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`). - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go` — typed / untyped domain methods. - Pure stdlib + `google/go-querystring`; no UI, no dotenv. -- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. Split by domain: `main.go`, `common.go`, `calendar.go`, `tasks.go`, `crm.go`, `apps.go`. CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. +- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea): + - `main.go` — entry point (docstring lists the command tree). + - `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag. + - `calendar.go`, `projects.go`, `tasks.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject, each registers its subject `cobra.Command` in `init()` and attaches verb subcommands (`list`/`get`/`create`/`update`/`delete`/…). + - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - **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. ## Rules @@ -20,6 +24,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - New endpoints go into the library first; CLI commands are thin wrappers. - Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them. - Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason. +- CLI commands follow **subject → verb** structure (`oo `), never `oo -`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/.go` for a genuinely new domain. +- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command. - No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only. - Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`. diff --git a/CHANGELOG.md b/CHANGELOG.md index fc84615..1baa3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,64 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] - 2026-04-24 + +### Changed — CLI **BREAKING** + +- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun` + layout is replaced with `oo `: + + | Old | New | + |---|---| + | `oo cal-list` | `oo calendar list` | + | `oo cal-events` | `oo calendar events` | + | `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` | + | `oo task-list` | `oo tasks list` | + | `oo task-add` | `oo tasks create` | + | `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) | + | `oo subtask-add` | `oo tasks subtask add` | + | `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) | + | `oo crm-add-contact --company …` | `oo companies create --name …` | + | `oo crm-add-contact --person-first …` | `oo persons create --first …` | + | `oo crm-deals` | `oo opportunities list` | + | `oo crm-deals --stages` | `oo opportunities stages` | + | `oo crm-add-deal` | `oo opportunities create` | + | `oo crm-cases` | `oo cases list` | + | `oo applications-sync` | `oo applications sync` | + +- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`, + `oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks + {list,create,delete,categories}`, `oo cases {create,delete,member-add}`, + `oo contacts {get,info-add}`. + +- **Global `--output/-o` flag**: all list-like commands now support + `--output table` (default; tabwriter-aligned, truncated to 80 chars + per cell) and `--output json`. Nested `bidCurrency` flattened to its + `abbreviation` in the table view. + +- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`, + `oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`, + `oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that + removes things. + +### Added + +- `cmd/oo/common.go` — shared `printTable(headers, rows)` and + `printObject(v)` helpers that dispatch on the `--output` flag. +- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami` + via the library's `GetUsers` + `SelfUserID`. +- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`, + `UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`. +- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from + project `oo tasks`). +- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared + list/filter implementation. + +### Changed — library (minor) + +- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())` + so CLI aborts propagate to the auth request. + ## [0.4.0] - 2026-04-24 ### Changed — project structure diff --git a/README.md b/README.md index 5b1f91f..18e2cd7 100644 --- a/README.md +++ b/README.md @@ -537,21 +537,53 @@ client.AddSubtask(ctx, "4242", "Prepare CV") ### oo (bundled command) -A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo`: +A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the +library lives under `cmd/oo`. The command tree is **subject-based**, mirroring +the [`tea`](https://gitea.com/gitea/tea) CLI: ```bash go install github.com/eslider/go-onlyoffice/cmd/oo@latest -oo cal-events -oo task-list --all --verbose -oo subtask-add 4242 "Prepare CV" -oo applications-sync --path ./applications/2026 --apply + +# Global: every list-style command takes -o table|json (default: table) +oo whoami +oo users list +oo calendar events --start 2026-04-24 --end 2026-05-01 +oo projects list +oo projects get 33 +oo tasks list --all --verbose +oo tasks subtask add 4242 "Prepare CV" +oo persons create --first Jane --last Doe --email jane@example.com +oo companies create --name "Acme GmbH" --website https://acme.com +oo opportunities list +oo opportunities stages +oo cases list +oo crm-tasks categories +oo applications sync --path ./applications/2026 --apply ``` -The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library -itself never loads dotfiles). Run `oo --help` for the command reference. +| Subject | Verbs | +|---|---| +| `calendar` | `list`, `events`, `add`, `delete` | +| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete` | +| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add` | +| `users` | `list`, `self` (alias: `oo whoami`) | +| `contacts` | `list`, `get`, `delete`, `info-add` | +| `persons` | `list` (filtered), `create`, `delete` | +| `companies` | `list` (filtered), `create`, `delete` | +| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add` | +| `cases` | `list`, `create`, `delete`, `member-add` | +| `crm-tasks` | `list`, `create`, `delete`, `categories` | +| `applications` | `sync` | -> **0.4.0 migration note:** the binary was previously named `oo-cli` and -> lived at `cmd/oo-cli`. The command set and flags are unchanged. +The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library +itself never loads dotfiles). Run `oo --help` or `oo --help` for +the full command reference. + +> **0.5.0 migration note:** the command tree was flattened per-subject. Old +> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, +> `oo applications-sync`, …) were replaced by subject-based equivalents +> (`oo calendar events`, `oo tasks list`, `oo contacts list`, +> `oo applications sync`). Flags on leaf commands are unchanged. ## Environment Variables diff --git a/cmd/oo/apps.go b/cmd/oo/apps.go index 92eeead..2d2c127 100644 --- a/cmd/oo/apps.go +++ b/cmd/oo/apps.go @@ -7,19 +7,30 @@ import ( "github.com/spf13/cobra" ) -func cmdAppsSync() *cobra.Command { +var applicationsCmd = &cobra.Command{ + Use: "applications", + Aliases: []string{"apps"}, + Short: "Job-application workflow (CV tree → CRM)", +} + +func init() { + rootCmd.AddCommand(applicationsCmd) + applicationsCmd.AddCommand(applicationsSyncCmd()) +} + +func applicationsSyncCmd() *cobra.Command { var apply, verbose bool var base string cmd := &cobra.Command{ - Use: "applications-sync", - Short: "Sync applications/*/README.md into CRM", + Use: "sync", + Short: "Sync applications/*/README.md into CRM (dry-run by default)", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } if base == "" { - return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)") + return fmt.Errorf("--path is required (points at applications year dir, e.g. .../applications/2026)") } paths, err := applications.Discover(base) if err != nil { diff --git a/cmd/oo/calendar.go b/cmd/oo/calendar.go index 5887121..888c012 100644 --- a/cmd/oo/calendar.go +++ b/cmd/oo/calendar.go @@ -6,12 +6,26 @@ import ( "github.com/spf13/cobra" ) -func cmdCalList() *cobra.Command { +var calendarCmd = &cobra.Command{ + Use: "calendar", + Aliases: []string{"cal"}, + Short: "Calendars and events", +} + +func init() { + rootCmd.AddCommand(calendarCmd) + calendarCmd.AddCommand(calListCmd()) + calendarCmd.AddCommand(calEventsCmd()) + calendarCmd.AddCommand(calAddCmd()) + calendarCmd.AddCommand(calDeleteCmd()) +} + +func calListCmd() *cobra.Command { return &cobra.Command{ - Use: "cal-list", - Short: "List calendars (default date span)", + Use: "list", + Short: "List calendars", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -19,19 +33,19 @@ func cmdCalList() *cobra.Command { if err != nil { return err } - printJSON(out) + printTable([]string{"objectId", "title", "textColor", "backgroundColor", "isEditable"}, out) return nil }, } } -func cmdCalEvents() *cobra.Command { +func calEventsCmd() *cobra.Command { var start, end string cmd := &cobra.Command{ - Use: "cal-events", - Short: "List calendar data for period (default: next 7 days)", + Use: "events", + Short: "List calendar events for a date range (default: next 7 days)", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -43,7 +57,7 @@ func cmdCalEvents() *cobra.Command { if err != nil { return err } - printJSON(out) + printTable([]string{"objectId", "title", "start", "end", "allDayLong"}, out) return nil }, } @@ -52,15 +66,15 @@ func cmdCalEvents() *cobra.Command { return cmd } -func cmdCalAdd() *cobra.Command { +func calAddCmd() *cobra.Command { var cal, desc string var allDay bool cmd := &cobra.Command{ - Use: "cal-add TITLE START END", - Short: "Add calendar event", + Use: "add TITLE START END", + Short: "Add a calendar event", Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -68,23 +82,24 @@ func cmdCalAdd() *cobra.Command { if err != nil { return err } - printJSON(ev) + printObject(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, "") + cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env OO_CALENDAR_ID)") + cmd.Flags().StringVar(&desc, "description", "", "event description") + cmd.Flags().BoolVar(&allDay, "all-day", false, "mark as all-day event") return cmd } -func cmdCalDel() *cobra.Command { +func calDeleteCmd() *cobra.Command { return &cobra.Command{ - Use: "cal-delete EVENT_ID [EVENT_ID...]", - Short: "Delete calendar events", - Args: cobra.MinimumNArgs(1), + Use: "delete EVENT_ID [EVENT_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more calendar events", + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -93,7 +108,7 @@ func cmdCalDel() *cobra.Command { if err != nil { return err } - printJSON(out) + printObject(out) } return nil }, diff --git a/cmd/oo/cases.go b/cmd/oo/cases.go new file mode 100644 index 0000000..5612767 --- /dev/null +++ b/cmd/oo/cases.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var casesCmd = &cobra.Command{ + Use: "cases", + Aliases: []string{"case"}, + Short: "CRM cases", +} + +func init() { + rootCmd.AddCommand(casesCmd) + casesCmd.AddCommand(casesListCmd()) + casesCmd.AddCommand(casesCreateCmd()) + casesCmd.AddCommand(casesDeleteCmd()) + casesCmd.AddCommand(casesMemberAddCmd()) +} + +func casesListCmd() *cobra.Command { + var count, offset int + cmd := &cobra.Command{ + Use: "list", + Short: "List CRM cases", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, total, err := c.ListCases(cmd.Context(), count, offset) + if err != nil { + return err + } + if outputFormat == "table" { + fmt.Printf("total: %d (shown: %d)\n", total, len(list)) + } + printTable([]string{"id", "title", "isClosed", "created"}, list) + return nil + }, + } + cmd.Flags().IntVar(&count, "count", 50, "") + cmd.Flags().IntVar(&offset, "offset", 0, "") + return cmd +} + +func casesCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create TITLE", + Aliases: []string{"add"}, + Short: "Create a case", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.CreateCase(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func casesDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete CASE_ID [CASE_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more cases", + 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 { + out, err := c.DeleteCase(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + } + return nil + }, + } +} + +func casesMemberAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "member-add CASE_ID CONTACT_ID", + Short: "Attach a contact to a case", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.AddCaseMember(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} diff --git a/cmd/oo/common.go b/cmd/oo/common.go index 7bb3c95..cd8ad45 100644 --- a/cmd/oo/common.go +++ b/cmd/oo/common.go @@ -5,40 +5,36 @@ import ( "fmt" "os" "strconv" + "strings" + "text/tabwriter" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/joho/godotenv" "github.com/spf13/cobra" ) +// outputFormat is the value of the global --output/-o flag. Valid values: +// "table" (default, tabwriter-rendered), "json" (machine-readable). +var outputFormat = "table" + var rootCmd = &cobra.Command{ - Use: "oo", - Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)", + Use: "oo", + Short: "OnlyOffice Workspace CLI — subject-based command tree", + Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).", + SilenceUsage: true, + SilenceErrors: false, +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|json") } // execute runs the root command. Exported only to main.go in the same package. func execute() error { return rootCmd.Execute() } -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()) -} - // 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) { +func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { _ = godotenv.Load() creds := onlyoffice.GetEnvironmentCredentials() if creds.Url == "" || creds.User == "" || creds.Password == "" { @@ -46,20 +42,110 @@ func newOO() (*onlyoffice.Client, error) { } c := onlyoffice.NewClient(creds) c.SetDefaults(onlyoffice.GetEnvironmentDefaults()) - if err := c.Authenticate(); err != nil { + if err := c.AuthenticateContext(cmd.Context()); err != nil { return nil, err } return c, nil } +// printJSON dumps any value as indented JSON. func printJSON(v any) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(v) } +// printObject renders a single value. JSON output dumps verbatim; table output +// prints key/value pairs when v is a map, otherwise falls back to JSON. +func printObject(v any) { + if outputFormat == "json" { + printJSON(v) + return + } + m, ok := v.(map[string]any) + if !ok { + printJSON(v) + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + for _, k := range sortedKeys(m) { + fmt.Fprintf(w, "%s\t%s\n", k, fmtCell(m[k])) + } + _ = w.Flush() +} + +// printTable renders a list of rows. headers select & order the columns. +// When outputFormat=="json" the raw slice is dumped as-is. +func printTable(headers []string, rows []map[string]any) { + if outputFormat == "json" { + printJSON(rows) + return + } + if len(rows) == 0 { + fmt.Println("(empty)") + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, strings.Join(headers, "\t")) + for _, row := range rows { + cells := make([]string, len(headers)) + for i, h := range headers { + cells[i] = fmtCell(row[h]) + } + fmt.Fprintln(w, strings.Join(cells, "\t")) + } + _ = w.Flush() +} + +// fmtCell turns an arbitrary value into a compact table cell. +func fmtCell(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return truncate(x, 80) + case bool: + if x { + return "true" + } + return "false" + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case int, int32, int64: + return fmt.Sprintf("%d", x) + default: + b, _ := json.Marshal(x) + return truncate(string(b), 80) + } +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > n { + return s[:n-1] + "…" + } + return s +} + +func sortedKeys(m map[string]any) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + // Deterministic ordering; small map so O(n log n) is fine. + for i := 1; i < len(ks); i++ { + for j := i; j > 0 && ks[j-1] > ks[j]; j-- { + ks[j-1], ks[j] = ks[j], ks[j-1] + } + } + return ks +} + // flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int -// / string into a float64. CLI-only; the library's own flexInt is unexported. +// / string into a float64. func flexIDFloat(v any) float64 { switch x := v.(type) { case float64: @@ -71,3 +157,21 @@ func flexIDFloat(v any) float64 { return f } } + +// idString returns the id field as a string (works for int/float/string). +func idString(m map[string]any, key string) string { + v, ok := m[key] + if !ok { + return "" + } + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatInt(int64(x), 10) + case int: + return strconv.Itoa(x) + default: + return fmt.Sprint(x) + } +} diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go new file mode 100644 index 0000000..72cefa4 --- /dev/null +++ b/cmd/oo/contacts.go @@ -0,0 +1,248 @@ +package main + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +// Contacts cover both persons and companies; OnlyOffice exposes them through +// the same `crm/contact/*` endpoint family. `oo persons` and `oo companies` +// are filtered views on top of `oo contacts list`, plus dedicated create +// commands that pick the right library call. + +var contactsCmd = &cobra.Command{ + Use: "contacts", + Aliases: []string{"contact"}, + Short: "CRM contacts (persons + companies)", +} + +var personsCmd = &cobra.Command{ + Use: "persons", + Aliases: []string{"person"}, + Short: "CRM persons (contacts with isCompany=false)", +} + +var companiesCmd = &cobra.Command{ + Use: "companies", + Aliases: []string{"company"}, + Short: "CRM companies (contacts with isCompany=true)", +} + +func init() { + rootCmd.AddCommand(contactsCmd) + rootCmd.AddCommand(personsCmd) + rootCmd.AddCommand(companiesCmd) + + contactsCmd.AddCommand(contactsListCmd(nil)) + contactsCmd.AddCommand(contactsGetCmd()) + contactsCmd.AddCommand(contactsDeleteCmd()) + contactsCmd.AddCommand(contactsInfoAddCmd()) + + only := true + personsCmd.AddCommand(contactsListCmd(&only)) // persons only + personsCmd.AddCommand(personsCreateCmd()) + personsCmd.AddCommand(contactsDeleteCmd()) + + onlyCo := false + companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only + companiesCmd.AddCommand(companiesCreateCmd()) + companiesCmd.AddCommand(contactsDeleteCmd()) +} + +// contactsListCmd returns a `list` subcommand. +// +// - personsOnly == nil → list all contacts +// - personsOnly == &true → keep only persons (isCompany=false) +// - personsOnly == &false → keep only companies (isCompany=true) +func contactsListCmd(personsOnly *bool) *cobra.Command { + var search string + var count, offset int + cmd := &cobra.Command{ + Use: "list", + Short: "List contacts", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, total, err := c.ListContacts(cmd.Context(), count, offset, search) + if err != nil { + return err + } + filtered := list + if personsOnly != nil { + filtered = filtered[:0] + for _, row := range list { + isCo, _ := row["isCompany"].(bool) + if *personsOnly && isCo { + continue + } + if !*personsOnly && !isCo { + continue + } + filtered = append(filtered, row) + } + } + if outputFormat == "table" { + fmt.Printf("total: %d (shown: %d)\n", total, len(filtered)) + } + printTable([]string{"id", "displayName", "isCompany", "email", "companyName"}, filtered) + return nil + }, + } + cmd.Flags().StringVar(&search, "search", "", "search filter") + cmd.Flags().IntVar(&count, "count", 50, "") + cmd.Flags().IntVar(&offset, "offset", 0, "") + return cmd +} + +func contactsGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get CONTACT_ID", + Short: "Show a contact by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.GetContact(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func contactsDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete CONTACT_ID [CONTACT_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more contacts", + 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 { + out, err := c.DeleteContact(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + } + return nil + }, + } +} + +func contactsInfoAddCmd() *cobra.Command { + var infoType, value, category string + var isPrimary bool + cmd := &cobra.Command{ + Use: "info-add CONTACT_ID", + Short: "Add a contact info entry (email, phone, website, linkedin, …)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if infoType == "" || value == "" { + return fmt.Errorf("--type and --value are required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.AddContactInfo(cmd.Context(), args[0], infoType, value, category, isPrimary) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVar(&infoType, "type", "", "Email|Phone|Website|LinkedIn|…") + cmd.Flags().StringVar(&value, "value", "", "value") + cmd.Flags().StringVar(&category, "category", "Work", "Work|Home|Other") + cmd.Flags().BoolVar(&isPrimary, "primary", false, "mark as primary") + return cmd +} + +func personsCreateCmd() *cobra.Command { + var first, last, email, linkedin string + var companyID int + var jobTitle, about string + cmd := &cobra.Command{ + Use: "create", + Aliases: []string{"add"}, + Short: "Create a person", + RunE: func(cmd *cobra.Command, args []string) error { + if first == "" || last == "" { + return fmt.Errorf("--first and --last are required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.CreatePerson(cmd.Context(), first, last, companyID, jobTitle, about) + if err != nil { + return err + } + pid := strconv.Itoa(int(flexIDFloat(out["id"]))) + if email != "" { + _, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true) + } + if linkedin != "" { + _, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false) + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVar(&first, "first", "", "first name") + cmd.Flags().StringVar(&last, "last", "", "last name") + cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id") + cmd.Flags().StringVar(&jobTitle, "job-title", "", "") + cmd.Flags().StringVar(&about, "about", "", "about / bio") + cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)") + cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)") + return cmd +} + +func companiesCreateCmd() *cobra.Command { + var name, email, website string + cmd := &cobra.Command{ + Use: "create", + Aliases: []string{"add"}, + Short: "Create a company", + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.CreateCompany(cmd.Context(), name) + if err != nil { + return err + } + cid := strconv.Itoa(int(flexIDFloat(out["id"]))) + if email != "" { + _, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true) + } + if website != "" { + _, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false) + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "company name") + cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)") + cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)") + return cmd +} diff --git a/cmd/oo/crm.go b/cmd/oo/crm.go deleted file mode 100644 index 2b6de45..0000000 --- a/cmd/oo/crm.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -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 := fmt.Sprint(int(flexIDFloat(out["id"]))) - if email != "" { - _, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true) - } - if website != "" { - _, _ = c.AddContactInfo(cmd.Context(), 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(flexIDFloat(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 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(flexIDFloat(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 - }, - } -} diff --git a/cmd/oo/crm_tasks.go b/cmd/oo/crm_tasks.go new file mode 100644 index 0000000..76eef3d --- /dev/null +++ b/cmd/oo/crm_tasks.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var crmTasksCmd = &cobra.Command{ + Use: "crm-tasks", + Aliases: []string{"crm-task"}, + Short: "CRM tasks (standalone, not project tasks)", +} + +func init() { + rootCmd.AddCommand(crmTasksCmd) + crmTasksCmd.AddCommand(crmTasksListCmd()) + crmTasksCmd.AddCommand(crmTasksCreateCmd()) + crmTasksCmd.AddCommand(crmTasksDeleteCmd()) + crmTasksCmd.AddCommand(crmTasksCategoriesCmd()) +} + +func crmTasksListCmd() *cobra.Command { + var count, offset int + cmd := &cobra.Command{ + Use: "list", + Short: "List CRM tasks", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, total, err := c.ListCRMTasks(cmd.Context(), count, offset) + if err != nil { + return err + } + if outputFormat == "table" { + fmt.Printf("total: %d (shown: %d)\n", total, len(list)) + } + printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list) + return nil + }, + } + cmd.Flags().IntVar(&count, "count", 50, "") + cmd.Flags().IntVar(&offset, "offset", 0, "") + return cmd +} + +func crmTasksCreateCmd() *cobra.Command { + var deadline, desc, entityType string + var categoryID, contactID, entityID int + cmd := &cobra.Command{ + Use: "create TITLE", + Aliases: []string{"add"}, + Short: "Create a CRM task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.CreateCRMTask(cmd.Context(), args[0], deadline, categoryID, contactID, entityType, entityID, desc) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)") + cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)") + cmd.Flags().IntVar(&contactID, "contact", 0, "contact id") + cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact") + cmd.Flags().IntVar(&entityID, "entity-id", 0, "parent entity id") + cmd.Flags().StringVar(&desc, "description", "", "description") + return cmd +} + +func crmTasksDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete TASK_ID [TASK_ID...]", + Aliases: []string{"rm"}, + Short: "Delete CRM tasks", + 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 := strconv.Atoi(id); err != nil { + return fmt.Errorf("task id %q must be integer: %w", id, err) + } + out, err := c.DeleteCRMTask(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + } + return nil + }, + } +} + +func crmTasksCategoriesCmd() *cobra.Command { + return &cobra.Command{ + Use: "categories", + Short: "List CRM task categories", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.ListTaskCategories(cmd.Context()) + if err != nil { + return err + } + printTable([]string{"id", "title", "sortOrder", "imagePath"}, out) + return nil + }, + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 704e5bd..50ea7df 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -1,14 +1,24 @@ // Command oo is a thin CLI over the github.com/eslider/go-onlyoffice library. -// It exposes calendar, CRM, tasks, subtasks, and application-sync commands. +// +// Command tree is subject-based (mirrors the library split and the `tea` CLI): +// +// oo calendar list | events | add | delete +// oo projects list | get | milestones | create | update | delete +// oo tasks list | get | create | update | delete | subtask add +// oo users list | self (alias: oo whoami) +// oo contacts list | get | delete | info-add +// oo persons list | create | delete +// oo companies list | create | delete +// oo opportunities list | get | create | delete | stages | member-add +// oo cases list | create | delete | member-add +// oo crm-tasks list | create | delete | categories +// oo applications sync +// +// Every list supports `--output/-o json|table` (table is the default). // // Build & install: // // go install github.com/eslider/go-onlyoffice/cmd/oo@latest -// -// Rationale: previous versions shipped as `oo-cli` with the cobra tree living -// in `internal/cli`. Since the CLI is a consumer of the library — not a part -// of its public surface — cobra lives right here in package main, split by -// domain (calendar.go, crm.go, tasks.go, apps.go). package main import ( diff --git a/cmd/oo/opportunities.go b/cmd/oo/opportunities.go new file mode 100644 index 0000000..a9f8e6c --- /dev/null +++ b/cmd/oo/opportunities.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var opportunitiesCmd = &cobra.Command{ + Use: "opportunities", + Aliases: []string{"deals", "deal"}, + Short: "CRM opportunities (a.k.a. deals)", +} + +func init() { + rootCmd.AddCommand(opportunitiesCmd) + opportunitiesCmd.AddCommand(oppListCmd()) + opportunitiesCmd.AddCommand(oppGetCmd()) + opportunitiesCmd.AddCommand(oppCreateCmd()) + opportunitiesCmd.AddCommand(oppDeleteCmd()) + opportunitiesCmd.AddCommand(oppStagesCmd()) + opportunitiesCmd.AddCommand(oppMemberAddCmd()) +} + +func oppListCmd() *cobra.Command { + var count, offset int + cmd := &cobra.Command{ + Use: "list", + Short: "List opportunities", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, total, err := c.ListOpportunities(cmd.Context(), count, offset) + if err != nil { + return err + } + if outputFormat == "table" { + fmt.Printf("total: %d (shown: %d)\n", total, len(list)) + // Flatten nested bidCurrency {abbreviation, ...} for readability. + for _, row := range list { + if cur, ok := row["bidCurrency"].(map[string]any); ok { + row["bidCurrency"] = cur["abbreviation"] + } + } + } + printTable([]string{"id", "title", "bidValue", "bidCurrency", "stageName"}, list) + return nil + }, + } + cmd.Flags().IntVar(&count, "count", 100, "") + cmd.Flags().IntVar(&offset, "offset", 0, "") + return cmd +} + +func oppGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get OPPORTUNITY_ID", + Short: "Show an opportunity by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.GetOpportunity(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func oppCreateCmd() *cobra.Command { + var stage int + var bid float64 + var currency, responsible, desc string + var contacts []string + cmd := &cobra.Command{ + Use: "create TITLE", + Aliases: []string{"add"}, + Short: "Create an opportunity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if stage == 0 { + stage = 1 + } + out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, responsible, currency, desc, bid) + if err != nil { + return err + } + did := strconv.Itoa(int(flexIDFloat(out["id"]))) + for _, cid := range contacts { + _, _ = c.AddOpportunityMember(cmd.Context(), did, cid) + } + printObject(out) + return nil + }, + } + cmd.Flags().IntVar(&stage, "stage", 1, "pipeline stage id") + cmd.Flags().Float64Var(&bid, "bid", 0, "bid value") + cmd.Flags().StringVar(¤cy, "currency", "EUR", "bid currency") + cmd.Flags().StringVar(&responsible, "responsible", "", "responsible user id") + cmd.Flags().StringVar(&desc, "description", "", "description") + cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id to add as member (repeatable)") + return cmd +} + +func oppDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more opportunities", + 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 { + out, err := c.DeleteOpportunity(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + } + return nil + }, + } +} + +func oppStagesCmd() *cobra.Command { + return &cobra.Command{ + Use: "stages", + Short: "List pipeline stages", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.ListDealStages(cmd.Context()) + if err != nil { + return err + } + printTable([]string{"id", "title", "sortOrder", "color"}, out) + return nil + }, + } +} + +func oppMemberAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "member-add OPPORTUNITY_ID CONTACT_ID", + Short: "Attach a contact to an opportunity", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.AddOpportunityMember(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go new file mode 100644 index 0000000..24ce3f8 --- /dev/null +++ b/cmd/oo/projects.go @@ -0,0 +1,231 @@ +package main + +import ( + "fmt" + "strconv" + + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +var projectsCmd = &cobra.Command{ + Use: "projects", + Aliases: []string{"prj"}, + Short: "OnlyOffice Projects", +} + +func init() { + rootCmd.AddCommand(projectsCmd) + projectsCmd.AddCommand(prjListCmd()) + projectsCmd.AddCommand(prjGetCmd()) + projectsCmd.AddCommand(prjMilestonesCmd()) + projectsCmd.AddCommand(prjCreateCmd()) + projectsCmd.AddCommand(prjUpdateCmd()) + projectsCmd.AddCommand(prjDeleteCmd()) +} + +func prjListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all projects", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, err := c.GetProjects() + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(list)) + for _, p := range list { + row := map[string]any{ + "id": derefInt(p.ID), + "title": p.String(), + "status": derefInt(p.Status), + } + if p.TaskCount != nil { + row["tasks"] = *p.TaskCount + } + if p.IsPrivate != nil { + row["private"] = *p.IsPrivate + } + rows = append(rows, row) + } + printTable([]string{"id", "title", "status", "tasks", "private"}, rows) + return nil + }, + } +} + +func prjGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get [PROJECT_ID]", + Short: "Show a single project (default: $OO_PROJECT_ID)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + id := "" + if len(args) == 1 { + id = args[0] + } + out, err := c.GetProjectByID(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func prjMilestonesCmd() *cobra.Command { + return &cobra.Command{ + Use: "milestones PROJECT_ID", + Short: "List milestones of a 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 must be integer: %w", err) + } + ms, err := c.GetProjectMilestones(&onlyoffice.Project{ID: &pid}) + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(ms)) + for _, m := range ms { + rows = append(rows, map[string]any{ + "id": derefInt64(m.ID), + "title": derefString(m.Title), + "status": derefInt64(m.Status), + }) + } + printTable([]string{"id", "title", "status"}, rows) + return nil + }, + } +} + +func prjCreateCmd() *cobra.Command { + var desc, resp string + cmd := &cobra.Command{ + Use: "create TITLE", + Short: "Create a new project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + p, err := c.CreateProject(onlyoffice.NewProjectRequest{ + Title: args[0], + Description: desc, + ResponsibleID: resp, + }) + if err != nil { + return err + } + printObject(map[string]any{ + "id": derefInt(p.ID), + "title": p.String(), + }) + return nil + }, + } + cmd.Flags().StringVar(&desc, "description", "", "project description") + cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)") + return cmd +} + +func prjUpdateCmd() *cobra.Command { + var title, desc, resp string + cmd := &cobra.Command{ + Use: "update PROJECT_ID", + Short: "Update project fields (only non-empty)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("project id must be integer: %w", err) + } + p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{ + ID: id, + Title: title, + Description: desc, + ResponsibleID: resp, + }) + if err != nil { + return err + } + printObject(map[string]any{ + "id": derefInt(p.ID), + "title": p.String(), + }) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "new title") + cmd.Flags().StringVar(&desc, "description", "", "new description") + cmd.Flags().StringVar(&resp, "responsible", "", "new responsible user id") + return cmd +} + +func prjDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete PROJECT_ID [PROJECT_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more projects", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, raw := range args { + id, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("project id %q must be integer: %w", raw, err) + } + p, err := c.DeleteProject(id) + if err != nil { + return err + } + printObject(map[string]any{"id": derefInt(p.ID), "title": p.String()}) + } + return nil + }, + } +} + +func derefString(p *string) string { + if p == nil { + return "" + } + return *p +} + +func derefInt(p *int) int { + if p == nil { + return 0 + } + return *p +} + +func derefInt64(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} diff --git a/cmd/oo/tasks.go b/cmd/oo/tasks.go index 6cdcfd0..ac0dbc4 100644 --- a/cmd/oo/tasks.go +++ b/cmd/oo/tasks.go @@ -6,14 +6,30 @@ import ( "github.com/spf13/cobra" ) -func cmdTaskList() *cobra.Command { +var tasksCmd = &cobra.Command{ + Use: "tasks", + Aliases: []string{"task"}, + Short: "Project tasks and subtasks", +} + +func init() { + rootCmd.AddCommand(tasksCmd) + tasksCmd.AddCommand(taskListCmd()) + tasksCmd.AddCommand(taskGetCmd()) + tasksCmd.AddCommand(taskCreateCmd()) + tasksCmd.AddCommand(taskUpdateCmd()) + tasksCmd.AddCommand(taskDeleteCmd()) + tasksCmd.AddCommand(subtaskCmd()) +} + +func taskListCmd() *cobra.Command { var project, status string var all, verbose bool cmd := &cobra.Command{ - Use: "task-list", + Use: "list", Short: "List project tasks", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -26,36 +42,54 @@ func cmdTaskList() *cobra.Command { 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"].([]any) - for _, s := range subs { - sm, _ := s.(map[string]any) - fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"]) + if verbose { + for i, t := range tasks { + d, err := c.GetTaskByID(cmd.Context(), idString(t, "id")) + if err == nil { + tasks[i] = d } } } + printTable([]string{"id", "title", "status", "priority", "responsible"}, tasks) return nil }, } - cmd.Flags().StringVarP(&project, "project", "p", "", "project id") + cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_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, "") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include subtasks & full details") 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", +func taskGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get TASK_ID", + Short: "Show a single task (incl. subtasks)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.GetTaskByID(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func taskCreateCmd() *cobra.Command { + var project, desc, deadline, prio string + cmd := &cobra.Command{ + Use: "create TITLE", + Aliases: []string{"add"}, + Short: "Create a project task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) if err != nil { return err } @@ -70,24 +104,79 @@ func cmdTaskAdd() *cobra.Command { if err != nil { return err } - printJSON(out) + printObject(out) return nil }, } - cmd.Flags().StringVar(&project, "project", "", "") - cmd.Flags().StringVar(&desc, "description", "", "") - cmd.Flags().StringVar(&deadline, "deadline", "", "") + cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)") + cmd.Flags().StringVar(&desc, "description", "", "description") + cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD") cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low") return cmd } -func cmdSubtaskAdd() *cobra.Command { +func taskUpdateCmd() *cobra.Command { + var status string + cmd := &cobra.Command{ + Use: "update TASK_ID", + Short: "Update task status", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if status == "" { + return fmt.Errorf("--status is required (open|closed)") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.UpdateTaskStatus(cmd.Context(), args[0], status) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVarP(&status, "status", "s", "", "new status: open|closed") + return cmd +} + +func taskDeleteCmd() *cobra.Command { return &cobra.Command{ - Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]", - Short: "Add subtask(s)", + Use: "delete TASK_ID [TASK_ID...]", + Aliases: []string{"rm"}, + Short: "Delete one or more tasks", + 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 { + out, err := c.DeleteTask(cmd.Context(), id) + if err != nil { + return err + } + printObject(out) + } + return nil + }, + } +} + +// subtaskCmd exposes `oo tasks subtask {add}` — keeps things tidy without a +// dedicated subtasks top-level command. +func subtaskCmd() *cobra.Command { + sub := &cobra.Command{ + Use: "subtask", + Short: "Subtasks of a parent task", + } + sub.AddCommand(&cobra.Command{ + Use: "add PARENT_TASK_ID TITLE [TITLE...]", + Short: "Add one or more subtasks", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newOO() + c, err := newOO(cmd) if err != nil { return err } @@ -97,44 +186,10 @@ func cmdSubtaskAdd() *cobra.Command { if err != nil { return err } - printJSON(out) + printObject(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 + }) + return sub } diff --git a/cmd/oo/users.go b/cmd/oo/users.go new file mode 100644 index 0000000..474825d --- /dev/null +++ b/cmd/oo/users.go @@ -0,0 +1,93 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var usersCmd = &cobra.Command{ + Use: "users", + Short: "Portal / workspace users", +} + +func init() { + rootCmd.AddCommand(usersCmd) + usersCmd.AddCommand(usersListCmd()) + usersCmd.AddCommand(usersSelfCmd()) + rootCmd.AddCommand(whoamiCmd()) +} + +func usersListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List portal users", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + users, err := c.GetUsers() + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(users)) + for _, u := range users { + rows = append(rows, map[string]any{ + "id": derefString(u.ID), + "userName": derefString(u.UserName), + "displayName": derefString(u.DisplayName), + "email": derefString(u.Email), + "isAdmin": derefBool(u.IsAdmin), + "status": derefInt(u.Status), + }) + } + printTable([]string{"id", "userName", "displayName", "email", "isAdmin", "status"}, rows) + return nil + }, + } +} + +func usersSelfCmd() *cobra.Command { + return &cobra.Command{ + Use: "self", + Short: "Show current user id (people/@self)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + id, err := c.SelfUserID(cmd.Context()) + if err != nil { + return err + } + printObject(map[string]any{"id": id}) + return nil + }, + } +} + +// whoamiCmd is a convenience shortcut at the root level. +func whoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Alias for `oo users self`", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + id, err := c.SelfUserID(cmd.Context()) + if err != nil { + return err + } + printObject(map[string]any{"id": id}) + return nil + }, + } +} + +func derefBool(p *bool) bool { + if p == nil { + return false + } + return *p +}