refactor!: rename oo-cli → oo, split library by domain, relocate applications

BREAKING:
- Binary renamed oo-cli → oo; install path is now
  github.com/eslider/go-onlyoffice/cmd/oo.
- Former internal/cli tree removed; cobra commands live in cmd/oo/ as
  package main, split by domain (calendar.go, crm.go, tasks.go, apps.go,
  common.go, main.go).
- internal/applications moved to cmd/oo/applications/ (CV-specific workflow;
  not a library feature).
- examples/applications removed (it depended on an internal package).

Library split (mechanical, zero API surface change):
- client.go  — Client, Credentials, Defaults, env helpers, NewClient.
- request.go — Request, Query, Time, Token, MetaResponse, Permissions.
- auth.go    — Authenticate/AuthenticateContext/InvalidateToken.
- http.go    — transport + ResponseArray/ResponseObject/postFormObject/
               putFormObject/deleteObject/unmarshalResponseObject (renamed
               from httpx.go).
- projects.go, tasks.go, users.go, calendar.go, crm.go, files.go — typed
  / untyped domain methods. tasks_extra.go merged into tasks.go.
- onlyoffice.go deleted (content redistributed).

AGENTS.md, CHANGELOG.md, README.md updated accordingly.

Made-with: Cursor
This commit is contained in:
2026-04-24 14:55:21 +01:00
parent d5d249424b
commit cff8145f8c
28 changed files with 1757 additions and 1832 deletions
-47
View File
@@ -1,47 +0,0 @@
# 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.
-20
View File
@@ -1,20 +0,0 @@
// 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)
}
}
+435
View File
@@ -0,0 +1,435 @@
// Package applications syncs job application README trees into OnlyOffice CRM
// (ported from cv/bin/office/sync-applications.py).
//
// This package lives under cmd/oo because it is a CV-specific workflow — not
// a general OnlyOffice feature — and is only consumed by the `oo` CLI.
// Keeping it in the binary tree prevents accidental adoption by external
// library consumers and makes the coupling explicit.
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]))
}
// RE2 does not support lookahead; consume the trailing delimiter (\n## or EOS)
// with a non-capturing alternation — the captured group 1 stops right before it.
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?:\n## |$)`).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
}
}
+53
View File
@@ -0,0 +1,53 @@
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)
}
}
// TestBuildSummaryFitAssessment covers the RE2-safe replacement of a previously
// lookahead-based regex that would panic at MustCompile time on Go regexp.
func TestBuildSummaryFitAssessment(t *testing.T) {
app := Data{Position: "SRE", Company: "Acme", Folder: "/tmp"}
withNextSection := "## Fit Assessment\nGreat fit overall.\nStrong background.\n## Next section\nUnrelated."
summary := buildSummary(app, withNextSection)
if !contains(summary, "Fit Assessment:") {
t.Fatalf("missing header in summary:\n%s", summary)
}
if !contains(summary, "Great fit overall.") {
t.Fatalf("missing body in summary:\n%s", summary)
}
if contains(summary, "Unrelated.") {
t.Fatalf("bled into next section:\n%s", summary)
}
eof := "## Fit Assessment\nOnly content at EOF."
summary = buildSummary(app, eof)
if !contains(summary, "Only content at EOF.") {
t.Fatalf("EOF case missing body:\n%s", summary)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/oo/applications"
"github.com/spf13/cobra"
)
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
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"time"
"github.com/spf13/cobra"
)
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 == "" {
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
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
}
// 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) {
_ = 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)
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64. CLI-only; the library's own flexInt is unexported.
func flexIDFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
+181
View File
@@ -0,0 +1,181 @@
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
},
}
}
+24
View File
@@ -0,0 +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.
//
// 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 (
"fmt"
"os"
)
func main() {
if err := execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
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]any
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"].([]any)
for _, s := range subs {
sm, _ := s.(map[string]any)
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
switch prio {
case "high":
p = 1
case "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
}