Files
go-onlyoffice/README.md
T
eSlider 3cc288d281
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 25s
Tests / Test (Go 1.25) (pull_request) Successful in 28s
feat(search): index embedded PDF attachment text (#42)
2026-09-16 17:34:04 +00:00

1030 lines
36 KiB
Markdown

# 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<br/>Issues, PRs, Milestones"]
end
subgraph "go-onlyoffice"
CL["Client"]
AUTH["Auth<br/>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<br/>Issues & PRs"]
CODE["Code Reviews"]
end
subgraph "Management World"
PM["Project Managers"]
OO["OnlyOffice CRM<br/>Gantt · Planning · Reports"]
EXEC["Executives<br/>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<br/>+ 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<br/>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:<br/>Start: Feb 13, End: Mar 1<br/>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<br/>every 15 min"]
HOOK["Gitea Webhook<br/>on issue events"]
CLI["Manual CLI<br/>on-demand"]
end
subgraph "Sync Engine"
S["Sync Job"]
MAP["Label → Project<br/>Mapping"]
MATCH["URL-based Task<br/>Matching"]
DATE["Date Translation<br/>Created → Start<br/>Deadline → End<br/>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: ./<server title>
oo dav fileops # active move/copy operations (status polling)
```
### Search (`oo search`)
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`: `<doc>.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.
### 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/<tool>`.
```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 <subject> --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 `<a href="…#…">` 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)