# go-onlyoffice [![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev) [![Tests](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml/badge.svg)](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml) [![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases) [![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers) Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically. Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting. ## Architecture ```mermaid graph TB subgraph "Developer Tools" GIT["Gitea / GitHub
Issues, PRs, Milestones"] end subgraph "go-onlyoffice" CL["Client"] AUTH["Auth
Token-based"] PRJ["Projects"] TSK["Tasks & Subtasks"] MS["Milestones"] USR["Users"] end subgraph "OnlyOffice CRM" GANTT["Gantt Charts"] PLAN["Project Planning"] RPT["Reports & Dashboards"] PM["PM Workflow"] end GIT -->|"sync issues"| CL CL --> AUTH AUTH --> PRJ AUTH --> TSK AUTH --> MS AUTH --> USR PRJ --> GANTT TSK --> GANTT MS --> PLAN TSK --> RPT PRJ --> PM ``` ## The Problem: Developers vs. Project Managers ```mermaid graph LR subgraph "Engineering World" DEV["Developers"] GITEA["Gitea / GitHub
Issues & PRs"] CODE["Code Reviews"] end subgraph "Management World" PM["Project Managers"] OO["OnlyOffice CRM
Gantt · Planning · Reports"] EXEC["Executives
Status Reports"] end DEV -->|"create issues"| GITEA GITEA -.->|"❌ invisible"| PM PM -->|"manual copy"| OO OO --> EXEC style GITEA fill:#f96,stroke:#333 style OO fill:#69f,stroke:#333 ``` **Without sync:** Project managers manually copy issue titles, deadlines, and status from Gitea into OnlyOffice. Developers don't update the CRM. Gantt charts rot. Reports lie. **With sync:** Issues flow automatically from Gitea to OnlyOffice with start dates, deadlines, and status. PMs get live Gantt charts. Developers keep working in Git. ```mermaid graph LR subgraph "Engineering World" DEV["Developers"] GITEA["Gitea / GitHub"] end subgraph "Sync Bridge" SYNC["go-onlyoffice
+ go-gitea-helpers"] end subgraph "Management World" OO["OnlyOffice CRM"] GANTT["Gantt Charts ✓"] RPT["Reports ✓"] end DEV -->|"create/close issues"| GITEA GITEA -->|"auto-sync"| SYNC SYNC -->|"create/update tasks"| OO OO --> GANTT OO --> RPT style SYNC fill:#4a4,stroke:#333,color:#fff ``` ## Installation ```bash go get github.com/eslider/go-onlyoffice ``` For Gitea sync (optional): ```bash go get github.com/eslider/go-gitea-helpers ``` ## Quick Start ### Connect and List Projects ```go client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) projects, _ := client.GetProjects() for _, p := range projects { fmt.Printf("[%d] %s — %d tasks\n", *p.ID, *p.Title, safeInt(p.TaskCountTotal)) } ``` ### Create a Project with Tasks and Deadlines ```go // Create a project project, _ := client.CreateProject(onlyoffice.NewProjectRequest{ Title: "Q1 2026 Release", Description: "Backend API v2 + mobile app redesign", }) // Create tasks with start/end dates (for Gantt chart) client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{ ProjectId: *project.ID, Title: "Design API schema", Description: "OpenAPI 3.1 spec for all endpoints", StartDate: onlyoffice.Time(time.Now()), Deadline: onlyoffice.Time(time.Now().AddDate(0, 0, 14)), Priority: 1, // High }) client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{ ProjectId: *project.ID, Title: "Implement auth service", Description: "JWT + OAuth2 + refresh tokens", StartDate: onlyoffice.Time(time.Now().AddDate(0, 0, 14)), Deadline: onlyoffice.Time(time.Now().AddDate(0, 1, 0)), }) ``` ### List and Filter Tasks ```go // Get all tasks for a project tasks, _ := client.GetTasks(onlyoffice.NewProjectGetTasksRequest(*project.ID)) for _, t := range tasks { status := "open" if t.Status != nil && *t.Status == onlyoffice.ProjectTaskStatusClosed { status = "closed" } fmt.Printf(" [%s] %s", status, *t.Title) if t.Deadline != nil { fmt.Printf(" (due: %s)", t.Deadline.Format("2006-01-02")) } fmt.Println() } ``` ### Update Task Status and Dates ```go // Close a task and set actual end date client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{ ID: taskID, Title: "Design API schema", Status: onlyoffice.ProjectTaskStatusClosed, Deadline: &onlyoffice.Time(time.Now()), }) ``` ### Get Milestones and Task Progress ```go milestones, _ := client.GetProjectMilestones(project) for _, ms := range milestones { active := int64(0) closed := int64(0) if ms.ActiveTaskCount != nil { active = *ms.ActiveTaskCount } if ms.ClosedTaskCount != nil { closed = *ms.ClosedTaskCount } total := active + closed fmt.Printf("Milestone: %s — %d/%d tasks done", *ms.Title, closed, total) if ms.Deadline != nil { fmt.Printf(" (deadline: %s)", ms.Deadline.Format("2006-01-02")) } fmt.Println() } ``` --- ## Use Case: Gitea → OnlyOffice Sync The primary use case is **bridging developer workflows with project management**. Developers create issues in Gitea; a sync job automatically mirrors them as OnlyOffice tasks with proper start/end dates, enabling PMs to work with Gantt charts without developers leaving their Git workflow. ### Sync Flow ```mermaid sequenceDiagram participant Dev as Developer participant Gitea participant Sync as Sync Job participant OO as OnlyOffice Dev->>Gitea: Create issue "Add OAuth2" Note over Gitea: issue.Created = Feb 13
issue.Deadline = Mar 1 Sync->>Gitea: GET /repos/{org}/*/issues Gitea-->>Sync: issues list (paginated) Sync->>OO: GET /api/2.0/project/filter.json OO-->>Sync: projects list Sync->>Sync: Match Gitea labels → OO projects alt Issue not yet in OnlyOffice Sync->>OO: POST /api/2.0/project/{id}/task.json Note over OO: Task created:
Start: Feb 13, End: Mar 1
Description includes Gitea URL else Issue already synced Sync->>OO: PUT /api/2.0/project/task/{id}.json Note over OO: Title, status, dates updated end Dev->>Gitea: Close issue "Add OAuth2" Sync->>OO: PUT status → Closed Note over OO: Gantt chart updates automatically ``` ### Sync Example ```go package main import ( "fmt" "log" "os" "strings" gitea "github.com/eslider/go-gitea-helpers" onlyoffice "github.com/eslider/go-onlyoffice" ) func main() { // Connect to both services oo := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) gc, _ := gitea.NewClient(gitea.GetEnvironmentConfig()) owner := os.Getenv("GITEA_OWNER") // Load all Gitea issues and OnlyOffice projects repos, _ := gc.GetAllReposIssues(owner) projects, _ := oo.GetProjects() for repoName, repo := range repos { // Find matching OnlyOffice project by name project := projects.Get(repoName) if project == nil { fmt.Printf("SKIP %s (no matching OO project)\n", repoName) continue } // Load existing tasks tasks, _ := oo.GetTasks(onlyoffice.NewProjectGetTasksRequest(*project.ID)) for _, issue := range repo.Issues { // Check if issue is already synced (URL in description) existing := findSyncedTask(tasks, issue.HTMLURL) if existing != nil { // Update existing task status := onlyoffice.ProjectTaskStatusOpen if issue.State == "closed" { status = onlyoffice.ProjectTaskStatusClosed } oo.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{ ID: *existing.ID, Title: issue.Title, Status: status, StartDate: timePtr(onlyoffice.Time(issue.Created)), Deadline: deadlineFromIssue(issue), }) fmt.Printf(" UPDATED: %s\n", issue.Title) } else { // Create new task status := onlyoffice.ProjectTaskStatusOpen if issue.State == "closed" { status = onlyoffice.ProjectTaskStatusClosed } oo.CreateProjectTask(onlyoffice.NewProjectTaskRequest{ ProjectId: *project.ID, Title: issue.Title, Description: issue.Body + "\n\nURL:" + issue.HTMLURL, StartDate: onlyoffice.Time(issue.Created), Deadline: *deadlineFromIssue(issue), Status: status, }) fmt.Printf(" CREATED: %s\n", issue.Title) } } } } // findSyncedTask checks task descriptions for the Gitea issue URL. func findSyncedTask(tasks []*onlyoffice.Task, issueURL string) *onlyoffice.Task { for _, t := range tasks { if t.Description != nil && strings.Contains(*t.Description, issueURL) { return t } } return nil } ``` ### What Project Managers Get Once synced, OnlyOffice provides without any developer intervention: | Feature | How It Works | |---|---| | **Gantt Charts** | Tasks have `StartDate` and `Deadline` from Gitea issue created/due dates | | **Status Tracking** | Open/closed status mirrors Gitea issue state in real time | | **Milestone Planning** | Gitea milestones map to OnlyOffice milestones with progress % | | **Resource Allocation** | Task assignees sync so PMs see who's working on what | | **Sprint Reports** | Filter by date range to generate sprint/release reports | | **Cross-Repo View** | All repos' issues appear as tasks in a unified project board | | **Executive Dashboards** | Project progress, overdue tasks, team workload at a glance | ### Recommended Sync Architecture ```mermaid graph TB subgraph "Trigger Options" CRON["Cron Job
every 15 min"] HOOK["Gitea Webhook
on issue events"] CLI["Manual CLI
on-demand"] end subgraph "Sync Engine" S["Sync Job"] MAP["Label → Project
Mapping"] MATCH["URL-based Task
Matching"] DATE["Date Translation
Created → Start
Deadline → End
Closed → Actual End"] end subgraph "Output" OO["OnlyOffice Tasks"] GANTT["Gantt Timeline"] REP["PM Reports"] end CRON --> S HOOK --> S CLI --> S S --> MAP S --> MATCH S --> DATE MAP --> OO MATCH --> OO DATE --> OO OO --> GANTT OO --> REP ``` --- ## Use Case: Task Lifecycle Management ### Creating Tasks with Full Metadata ```go task, _ := client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{ ProjectId: projectID, Title: "Implement payment gateway", Description: "Integrate Stripe API for subscription billing", StartDate: onlyoffice.Time(time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)), Deadline: onlyoffice.Time(time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)), Priority: 1, // High MilestoneId: milestoneID, Notify: true, }) ``` ### Tracking Task Progress ```go tasks, _ := client.GetTasks(onlyoffice.NewProjectGetTasksRequest(projectID)) open, closed := 0, 0 var overdue []*onlyoffice.Task for _, t := range tasks { if t.Status != nil && *t.Status == onlyoffice.ProjectTaskStatusClosed { closed++ } else { open++ if t.Deadline != nil && t.Deadline.Before(time.Now()) { overdue = append(overdue, t) } } } fmt.Printf("Progress: %d/%d done (%.0f%%)\n", closed, open+closed, float64(closed)/float64(open+closed)*100) if len(overdue) > 0 { fmt.Printf("⚠ %d overdue tasks:\n", len(overdue)) for _, t := range overdue { fmt.Printf(" - %s (due: %s)\n", *t.Title, t.Deadline.Format("2006-01-02")) } } ``` ### Subtask Management Tasks support subtasks for breaking work into smaller pieces: ```go type Task struct { ID *int `json:"id"` Title *string `json:"title"` StartDate *time.Time `json:"startDate"` Deadline *time.Time `json:"deadline"` Description *string `json:"description"` Priority *int `json:"priority"` // High=1, Normal=0, Low=-1 Status *ProjectTaskStatus `json:"status"` // Open=1, Closed=2 Subtasks []any `json:"subtasks"` MilestoneID *int64 `json:"milestoneId"` Responsibles []*User `json:"responsibles"` // Assigned team members // ... timestamps, permissions } ``` --- ## API Reference ### Client | Function | Description | |---|---| | `NewClient(credentials)` | Create a new API client | | `GetEnvironmentCredentials()` | Load from `ONLYOFFICE_*` env vars | | `Auth(credentials)` | Authenticate and get token | | `Query(request, result)` | Execute raw API request | ### Projects | Method | Description | |---|---| | `GetProjects()` | List all projects | | `CreateProject(req)` | Create a new project | | `UpdateProject(req)` | Update project details | | `DeleteProject(id)` | Delete a project | | `GetProjectMilestones(project)` | Get milestones with task counts | ### Tasks | Method | Description | |---|---| | `GetTasks(req)` | List tasks with filtering | | `CreateProjectTask(req)` | Create task with dates, priority, milestone | | `UpdateProjectTask(req)` | Update title, status, dates, priority | ### Task Fields for Gantt | Field | Type | Purpose | |---|---|---| | `StartDate` | `*time.Time` | Gantt bar start | | `Deadline` | `*time.Time` | Gantt bar end | | `Status` | `ProjectTaskStatus` | Open (1) / Closed (2) | | `Priority` | `*int` | High (1) / Normal (0) / Low (-1) | | `MilestoneID` | `*int64` | Groups tasks under milestones | | `Responsibles` | `[]*User` | Assigned team members | | `Subtasks` | `[]any` | Sub-items within a task | ### Users | Method | Description | |---|---| | `GetUsers()` | List all users with profiles | ### Documents Files | Method | Description | |---|---| | `ListDavFolder(ctx, id)` | List a Documents folder (`@root` for virtual sections) | | `ListDavSections(ctx)` | Virtual sections (Documents, Projects, …) | | `CreateDavFolder(ctx, parentID, title)` | Create a subfolder | | `RenameDavFolder(ctx, id, title)` / `RenameDavFile(ctx, id, title)` | Rename folder / file | | `DownloadFile(ctx, id, dst)` / `DownloadDavFile(ctx, id, w)` | Download file bytes | | `UploadDavFile(ctx, folderID, fileName, src)` | Upload from a reader | | `UploadToFolder(ctx, folderID, localPath)` | Upload a local file into a folder | | `UploadToFolderReplacing(ctx, folderID, localPath)` | Upsert by `stem\|ext`; returns replaced ids | | `UpdateFile(ctx, fileID, localPath)` | New version of an existing file (same id, no copy) | | `MoveDavItems(ctx, folderIDs, fileIDs, dest)` | Move (`resolveType=Skip`); per-operation errors surfaced, not silent nil | | `CopyDavItems(ctx, folderIDs, fileIDs, dest)` | Copy (`conflictResolveType=Skip`); errors surfaced | | `MoveFiles(ctx, destFolderID, fileIDs)` | Move with `resolveType=Skip` + `holdResult`; errors surfaced | | `ListFileOps(ctx)` | Active file operations (move/copy status polling) | | `FolderFiles(ctx, folderID)` | Flat file list of a folder (stem helpers) | | `DeleteFilesByStem(ctx, folderID, stem)` | Remove `stem\|ext` copies | | `DoRetry(ctx, policy, fn)` | Deterministic linear backoff (N·Base, no jitter) on 429/502/503/504 | | `DefaultRetryPolicy()` | 5 attempts, 1s·2s·3s·4s waits, 30s cap | | `Transient(err)` | True for retriable OnlyOffice answers | ### Helper Types | Type | Description | |---|---| | `Projects` | `[]*Project` with `.Get(title)` lookup | | `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 notes") ``` ### oo (bundled CLI) A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the library lives under [`cmd/oo`](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 # 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 notes" 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 ``` ### office (TUI) Terminal UI for browsing OnlyOffice Workspace — module tree (left), selectable lists (center), and markdown preview (right). Uses the same `.env` credentials as `oo`. Lives under [`cmd/office`](cmd/office/). ```bash go install github.com/eslider/go-onlyoffice/cmd/office@latest office ``` | Key | Action | |---|---| | `Tab` | Next pane (menu → list → preview) | | `Shift+Tab` | Previous pane | | `↑↓` / `j` / `k` | Navigate / scroll within focused pane | | `Enter` / `→` | Expand tree branch or open leaf list | | `Space` | Toggle multi-select on list row | | `Enter` / `a` | Action menu (view, delete, download, …) | | `r` | Refresh current list | | `q` | Quit | Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves. Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)): ```bash ONLYOFFICE_DOCS_URL=https://docs.example.com ONLYOFFICE_DOCS_SECRET=… # when JWT signing is enabled ``` Spreadsheet files: inline CSV/JSON preview in the right pane; install [`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen xlsx/csv viewing (`v` on a file row — coming in next iteration). Integration tests for list loaders and preview (live API only — no mocks): ```bash go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/... ``` **Project / task documents (`oo`):** ```bash # Project Documents (files module) oo projects files list 33 oo projects files upload 33 ./notes.docx oo projects files download 12345 --to ./copy.docx oo projects files rename 12345 notes-v2.docx oo projects files delete 12345 oo projects files dedupe 7 # dry-run duplicate report oo projects files dedupe 7 --apply # remove older stem|ext copies per folder oo projects files dedupe 7 --cross --apply # cross-folder; keep non-_trash # Agent document pipeline (md in git ↔ docx in OO; OCR scans) oo docs tools oo docs convert ./note.md # → note.docx oo docs convert ./note.docx # → note.md oo docs ocr ./scan.jpg --md ./scan.md # searchable PDF + markdown oo docs hocr ./scan.jpg --lang spa --md ./scan.hocr.md --yaml ./scan.yml oo docs put-md 7 ./OO-HONDA-7-INDEX.md --folder 490 oo docs put-txt 7 ./notes.txt --folder 490 oo docs put-xlsx 7 ./table.xlsx --folder 490 oo docs as-md 2815 --to ./parte.md # download OO file as MD (OCR if needed) oo docs as-md 307 --hocr --lang spa # OO download via go-hocr structure oo projects files put-md 7 ./note.md # alias oo projects files as-md 2815 # alias oo tasks files list 208 oo tasks files upload 208 ./notes.pdf oo tasks files detach 208 12345 ``` ### Documents module (`oo dav`) Direct access to the Documents module by folder/file id — the same calls that back `oo-webdav` and the project/task file commands. `move` sends `resolveType=Skip` + `holdResult=true`: without those params the legacy `fileops/move` endpoint answers 200 without moving anything, and the library surfaces such per-operation errors instead of a silent nil (`MoveDavItems` / `CopyDavItems` / `MoveFiles`). ```bash oo dav ls 659 oo dav ls @root # virtual sections (Documents, Projects, …) oo dav mkdir 659 "2026 inbox" oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID… oo dav move 659 22881 --folders 670 # move folders along with files oo dav copy 659 22881 oo dav rename-file 22881 invoice-v2.pdf oo dav rename-folder 671 o2-archive oo dav download 22881 --to ./copy.pdf # default path: ./ oo dav fileops # active move/copy operations (status polling) ``` ### Search and index (`oo search`, `oo index`) Full-text search over the Documents index. The REST endpoint `/api/2.0/files/@search/{query}` only searches file names in the database, so `oo search` talks to the OnlyOffice **Elasticsearch** directly (index `files_file`). Name search is default; `--content` also matches extracted document text (`document.attachment.content`, Office formats only). See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the tunnel setup. ```bash oo search "Rechnung" # names only oo search "Mahngebühr" --content # names + document text oo search "Rechnung" --folder 649 --limit 50 oo search "Rechnung" --json # shorthand for -o json ``` Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`, `ONLYOFFICE_TENANT`). #### PDF/scans: own index (`oo index` + `--backend own`) The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice numbers) are not searchable by content. `oo index` extracts PDF text with `internal/docpipe` (pdftotext, OCR for scans) — including the text of embedded PDF attachments (`pdfdetach`: `.md`, `.xml`, covers the original/scan and ZUGFeRD e-invoice XML) — into a separate index (`ONLYOFFICE_ES_TEXT_INDEX`, default `oo_docs_text`); the OnlyOffice server and its index are **not** modified. Then search it with `--backend own`. ```bash oo index folder 634 --recursive --exts pdf # populate (idempotent upsert) oo index files 3576 3578 # specific files oo index folder 634 --dry-run # plan only oo search "S1021" --content --backend own # finds the PDF oo search "Rechnung" --backend own --folder 634 --json ``` See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the decision and trade-offs. ### Unified file client All file backends (REST, WebDAV, read-only SQL, Elasticsearch) sit behind one facade: `c.Files()` returns a `*FileClient` that also implements `FileStore`, so old call sites keep working. Pick a transport per call with `c.FileStore("rest"|"dav"|"pg"|"sql")` (SQL is read-only), open the SQL store with `c.SQLFileStore()`, or register a backend on the facade (`RegisterStore`/`RegisterSearcher`). Contract, model (`Entry`/`Kind`), fallback rules, env names and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md). ### Bulk tools (`cmd/`) Small single-purpose binaries for bulk Documents work. All of them pace requests and retry transient OnlyOffice answers (429/502/503/504) with a deterministic linear backoff — no jitter, same waits on every run (see `DoRetry` below). Build with `go build ./cmd/`. ```bash ooscan 659 # recursive index → TSV: file_id, folder_id, path, title ooscan 659 666 > oo-index.tsv # several roots into one index pdfamount 671 # "Zu zahlender Betrag" per PDF → TSV: file_id, title, amount kontoblatt 3906 ./kontoblatt.xlsx # summary (Gegenkonto/Monat) uploaded next to source kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV] # kontolink writes DocEditor links into the Link column: Beleg → supplier+month # → amount+date (5th arg = pdfamount output); with FILE_ID it updates the # source file in place, else uploads an "(links)" copy next to it. ``` | Subject | Verbs | |---|---| | `calendar` | `list`, `events`, `add`, `delete` | | `projects` | `list`, `get`, `milestones`, `milestone-create`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) | | `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) | | `users` | `list`, `self` (alias: `oo whoami`) | | `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` | | `persons` | `list`, `create`, `delete`, `dedupe` | | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` | | `crm` | `cleanup` | | `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` | | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` | | `docs` | `tools`, `convert`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` | | `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` | | `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` | | `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) | | `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) | The CLI reads only `.env` from the current working directory (godotenv is a CLI-only concern — the library itself never loads dotfiles). Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases: `OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`. 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`, …) were > replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`, > `oo contacts list`). Flags on leaf commands are unchanged. ## oo CLI use cases The `oo` binary is the day-to-day operator interface. It loads credentials from `.env` in the **current working directory** (copy from [`.env.example`](.env.example)): ```bash cp .env.example .env # ONLYOFFICE_URL=https://office.example.com # ONLYOFFICE_USER=you@example.com # ONLYOFFICE_PASS=… go install github.com/eslider/go-onlyoffice/cmd/oo@latest oo whoami ``` Every list command accepts `-o table` (default) or `-o json` for scripting. ### CRM cleanup after imports or sync drift **Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice, the same email on a contact three times, deals titled ` @ Acme`, or the same HR contact linked to a deal twice. **One-shot fix** — runs every dedupe pass in order: ```bash oo crm cleanup -o json ``` Steps inside `crm cleanup`: | Step | What it does | |------|----------------| | `companies` | Merge companies with the same normalized name (slogan variants like `Affirm` / `Affirm — Fraud Engineering` count as one) | | `persons` | Merge duplicate persons globally (same first+last) | | `company-persons` | Merge duplicate persons under each company | | `contact-info` | Remove duplicate email/phone/website rows | | `opportunity-members` | Drop duplicate contacts on the same deal | | `opportunities` | Merge duplicate deals by title | | `fix-titles` | Repair malformed titles (` @ Company` → `Company`) | **Targeted passes** when you only want one kind of fix: ```bash # Duplicate company records oo companies dedupe # Same person entered twice under one employer oo companies dedupe-persons # Global person duplicates (same name, different ids) oo persons dedupe # Repeated email/phone rows on contacts oo contacts dedupe-info # Two deals with the same title oo opportunities dedupe # Same contact attached twice to one deal oo opportunities dedupe-members # Titles like " @ Acme" or extra whitespace oo opportunities fix-titles ``` Merge two known company ids (keeps `INTO`): ```bash oo contacts merge FROM_ID INTO_ID ``` Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks: [docs/crm-associations.md](docs/crm-associations.md). ### Invoices (`oo invoices`) **Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF attachments, prepare OnlyOffice Mail — without inventing a second bill-to company. ```bash # Always pass --opportunity at create (update --opportunity often HTTP 400) oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \ --price 300 --opportunity DEAL_ID --language de-DE \ --line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID" oo invoices pdf INVOICE_ID --force oo invoices pdf-cleanup INVOICE_ID oo invoices status INVOICE_ID --status draft oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com ``` **Deal grouping flag** — when the same role at the same company created separate deals (`Engineer @ Acme` vs `Engineer`): ```bash oo opportunities dedupe --ignore-company-suffix oo crm cleanup --ignore-company-suffix ``` **Inspect before/after:** ```bash oo opportunities list --count 200 oo contacts get CONTACT_ID -o json oo crm cleanup -o json ``` ### Workspace mail (`oo mails`) **Problem:** Mail lives in OnlyOffice Mail (`/addons/mail/#inbox`), bound to your portal account — not a separate archive service. You want to list, read, or remove messages from the shell. Uses the same `ONLYOFFICE_*` credentials as every other `oo` command. ```bash # Which mailbox is linked? oo mails accounts # Folder counters (inbox unread, trash size, …) oo mails folders # Latest inbox messages (API returns 25 per page; --limit paginates automatically) oo mails list --folder inbox --limit 50 # Page through older mail oo mails list --folder inbox --limit 100 --offset 100 # Other folders oo mails list --folder sent --limit 20 oo mails list --folder spam --limit 100 # Read full message (subject, htmlBody, attachments metadata) oo mails get 5664 -o json | jq '{subject, from, to, date}' # Remove one or more messages (server moves to trash or deletes per Mail rules) oo mails delete 5664 oo mails delete 5664 5663 5661 # Create / update a draft (HTML body field is API "body"; plain text is wrapped) oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \ --body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen" # Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft oo mails attach 7301 --file-id 12345 # Regenerate invoice PDF (force) + draft + attach (does not send) oo mails draft-invoice --invoice 16 --to info@example.com ``` Matrix / chat URLs in signatures: use plain text `chat: https://matrix.to/#/@user:server` — HTML `` truncates at `#`. **Table output** splits the `from` header into `fromName` and `fromAddress` (e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw API payload. **Scripting example** — export today's inbox subjects: ```bash oo mails list --folder inbox --limit 200 -o json \ | jq -r '.[] | "\(.id)\t\(.subject)"' ``` ### Contacts, companies, and deals (everyday CRM) ```bash # Search companies oo companies list --search acme # Create company + person with primary email oo companies create --name "Acme GmbH" --website https://acme.com oo persons create --first Jane --last Doe --email jane@acme.com --company-id 42 # Attach email or LinkedIn to existing contact oo contacts info-add 42 --type Email --value jane@acme.com --primary # Pipeline overview oo opportunities list --count 100 oo opportunities stages oo opportunities get 231 -o json ``` ### Calendar and project ops ```bash # Next week's events oo calendar events --start 2026-06-24 --end 2026-07-01 # Schedule interview block oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z # Attach a file to a task oo tasks files upload 208 ./notes.pdf oo projects files list 33 ``` ### Suggested maintenance cadence | When | Command | |------|---------| | After bulk CRM imports | `oo crm cleanup` | | After bulk CSV import into CRM | `oo crm cleanup` | | Weekly inbox triage | `oo mails list --folder inbox --limit 100` | | Before exec reporting | `oo opportunities list` + `oo projects list` | ## Environment Variables | Variable | Description | |---|---| | `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`) | | `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` | Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling. ### CI / releases GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go-config)): | Workflow | Trigger | Purpose | |---|---|---| | `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` | | `release-please.yml` | push to `main` | semver PR from conventional commits | | `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries | Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**. Merge the release-please PR to tag a version; GoReleaser publishes assets to [GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases). ## Examples | Example | Description | |---|---| | [basic](examples/basic/) | List projects and users | | [calendar](examples/calendar/) | List calendars and events, create a new event | | [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note | | [subtasks](examples/subtasks/) | Create a parent task and attach subtasks | | [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules | | [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview | ## Related Libraries | Library | Description | |---|---| | [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) | Gitea pagination helpers for issue/repo fetching | | [go-matrix-bot](https://github.com/eSlider/go-matrix-bot) | Matrix bot with OnlyOffice task creation from chat | | [go-trade](https://github.com/eSlider/go-trade) | Unified trade data model across exchanges | ## License [MIT](LICENSE)