feat(office): add Workspace TUI with shared bootstrap and test suite

Introduce the office Bubble Tea browser (module tree, multi-select lists,
markdown preview) alongside oo, with cmd/internal/bootstrap for shared auth,
unit tests across preview/model/ui/fetch, CI build for both binaries, and
GoReleaser archives for office.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 16:29:04 +01:00
co-authored by Cursor
parent 65f157b0c2
commit 7358ff5c32
32 changed files with 2267 additions and 94 deletions
+4
View File
@@ -21,3 +21,7 @@ ONLYOFFICE_CALENDAR_ID=1
ONLYOFFICE_PROJECT_ID=33 ONLYOFFICE_PROJECT_ID=33
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon). # oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
# ONLYOFFICE_DOCS_URL=https://docs.example.com
# ONLYOFFICE_DOCS_SECRET=
+3
View File
@@ -42,5 +42,8 @@ jobs:
- name: Build oo - name: Build oo
run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo
- name: Build office
run: go build -trimpath -buildvcs=false -o /tmp/office ./cmd/office
- name: Run tests - name: Run tests
run: go test -race -shuffle=on -count=1 ./... run: go test -race -shuffle=on -count=1 ./...
+31 -2
View File
@@ -31,6 +31,24 @@ builds:
- -X main.version={{.Version}} - -X main.version={{.Version}}
- -X main.commit={{.Commit}} - -X main.commit={{.Commit}}
- -X main.date={{.Date}} - -X main.date={{.Date}}
- id: office
main: ./cmd/office
binary: office
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
archives: archives:
- id: oo - id: oo
@@ -43,6 +61,16 @@ archives:
- LICENSE - LICENSE
- README.md - README.md
- CHANGELOG.md - CHANGELOG.md
- id: office
ids: [office]
name_template: "office_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
checksum: checksum:
name_template: checksums.txt name_template: checksums.txt
@@ -82,14 +110,15 @@ release:
prerelease: auto prerelease: auto
mode: replace mode: replace
header: | header: |
## `oo` {{ .Tag }} ## Release {{ .Tag }}
Install: Install:
```sh ```sh
go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }} go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }}
go install github.com/eslider/go-onlyoffice/cmd/office@{{ .Tag }}
``` ```
Or download a prebuilt binary below. Or download prebuilt binaries below.
footer: | footer: |
**Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }} **Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }}
+2
View File
@@ -16,6 +16,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag. - `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`. - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature. - **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
## Rules ## Rules
+11
View File
@@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added — `office` TUI
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace:
three-pane layout (module tree, multi-select list, markdown preview).
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
and `office`.
- Unit tests for preview formatters, menu/selection model, fetch mappers, and
key routing; integration tests under `cmd/office/fetch/` (`-tags=integration`).
### Added — library ### Added — library
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`: - **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
@@ -36,6 +46,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Changed — CLI ### Changed — CLI
- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts).
- `oo applications sync` — empty position uses company-only deal title; skips - `oo applications sync` — empty position uses company-only deal title; skips
duplicate opportunity members; paginated find-or-create lookups. duplicate opportunity members; paginated find-or-create lookups.
- `oo` now loads OnlyOffice credentials only from `.env` in the current - `oo` now loads OnlyOffice credentials only from `.env` in the current
+247 -19
View File
@@ -536,11 +536,11 @@ company, _ := client.FindCompany(ctx, "ACME")
client.AddSubtask(ctx, "4242", "Prepare CV") client.AddSubtask(ctx, "4242", "Prepare CV")
``` ```
### oo (bundled command) ### oo (bundled CLI)
A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
library lives under `cmd/oo`. The command tree is **subject-based**, mirroring library lives under [`cmd/oo`](cmd/oo/). The command tree is **subject-based**,
the [`tea`](https://gitea.com/gitea/tea) CLI: mirroring the [`tea`](https://gitea.com/gitea/tea) CLI:
```bash ```bash
go install github.com/eslider/go-onlyoffice/cmd/oo@latest go install github.com/eslider/go-onlyoffice/cmd/oo@latest
@@ -560,7 +560,48 @@ oo opportunities stages
oo cases list oo cases list
oo crm-tasks categories oo crm-tasks categories
oo applications sync --path ./applications/2026 --apply oo applications sync --path ./applications/2026 --apply
```
### 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` | Switch pane (menu → list → preview) |
| `↑↓` / `j` / `k` | Navigate |
| `Space` / `Enter` | Toggle selection on list row |
| `Enter` | Load preview for focused row |
| `r` | Refresh current list |
| `q` | Quit |
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:
```bash
go test -tags=integration ./cmd/office/fetch/...
```
**Project / task documents (`oo`):**
```bash
# Project Documents (files module) # Project Documents (files module)
oo projects files list 33 oo projects files list 33
oo projects files upload 33 ./notes.md oo projects files upload 33 ./notes.md
@@ -606,6 +647,205 @@ Run `oo --help` or `oo <subject> --help` for the full command reference.
> (`oo calendar events`, `oo tasks list`, `oo contacts list`, > (`oo calendar events`, `oo tasks list`, `oo contacts list`,
> `oo applications sync`). Flags on leaf commands are unchanged. > `oo applications sync`). 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 ` @ 711media`, 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 (common after applications sync)
oo opportunities dedupe-members
# Titles like " @ 711media" or extra whitespace
oo opportunities fix-titles
```
**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 | grep -i 711media
oo contacts get 857 -o json
oo crm cleanup -o json
```
### Job applications → CRM (`applications sync`)
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
and want companies, persons, deals, and history notes in OnlyOffice without
re-typing.
**Dry-run first** (default — prints what would happen, writes nothing):
```bash
oo applications sync --path ./applications/2026 --verbose
```
**Apply** when the preview looks right:
```bash
oo applications sync --path ./applications/2026 --apply --verbose
```
Each `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
The sync creates or finds contacts, opens a deal, adds members, and appends a
history note. Re-running is safe: duplicate members and duplicate deal titles
are skipped when already present.
**After a large sync**, run CRM cleanup to collapse duplicates introduced by
repeated runs or manual edits:
```bash
oo applications sync --path ./applications/2026 --apply
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
```
**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 CV to a hiring task
oo tasks files upload 208 ./cv.pdf
oo projects files list 33
```
### Suggested maintenance cadence
| When | Command |
|------|---------|
| After `applications sync --apply` | `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 ## Environment Variables
| Variable | Description | | Variable | Description |
@@ -617,20 +857,7 @@ Run `oo --help` or `oo <subject> --help` for the full command reference.
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | | `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` | | `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
### Workspace mail (`oo mails`) Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above.
Uses the same OnlyOffice credentials as other `oo` commands. Talks to the
built-in Mail addon (`/addons/mail`) via `/api/2.0/mail/*`:
```bash
oo mails accounts
oo mails folders
oo mails list --folder inbox --limit 20
oo mails get 5664
oo mails delete 5664
```
Folder names: `inbox`, `sent`, `drafts`, `trash`, `spam` (or numeric id).
### CI / releases ### CI / releases
@@ -638,9 +865,9 @@ GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go
| Workflow | Trigger | Purpose | | Workflow | Trigger | Purpose |
|---|---|---| |---|---|---|
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` | | `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
| `release-please.yml` | push to `main` | semver PR from conventional commits | | `release-please.yml` | push to `main` | semver PR from conventional commits |
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` binaries | | `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**. Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**.
@@ -655,6 +882,7 @@ Merge the release-please PR to tag a version; GoReleaser publishes assets to [Gi
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note | | [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks | | [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
| [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules | | [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
| [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview |
## Related Libraries ## Related Libraries
+50
View File
@@ -0,0 +1,50 @@
// Package bootstrap loads CLI environment and constructs an authenticated
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
// loads dotfiles.
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
)
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
func LoadEnv() {
_ = godotenv.Load(".env")
applyEnvAliases()
}
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
LoadEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(ctx); err != nil {
return nil, err
}
return c, nil
}
func applyEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
+95
View File
@@ -0,0 +1,95 @@
package bootstrap_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.produktor.io\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.produktor.io" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
_, err = bootstrap.NewClient(context.Background())
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
@@ -0,0 +1,45 @@
//go:build integration
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationListProjects(t *testing.T) {
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
loader := &fetch.Loader{Client: client}
items, err := loader.List(context.Background(), model.SubjectProjects)
if err != nil {
t.Fatal(err)
}
t.Logf("projects: %d", len(items))
}
func TestIntegrationListMailInbox(t *testing.T) {
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
loader := &fetch.Loader{Client: client}
items, err := loader.List(context.Background(), model.SubjectMailInbox)
if err != nil {
t.Fatal(err)
}
t.Logf("mail messages: %d", len(items))
}
+243
View File
@@ -0,0 +1,243 @@
package fetch
import (
"context"
"fmt"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct {
Client *onlyoffice.Client
}
// List returns items for the given subject.
func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
switch subject {
case model.SubjectProjects:
return l.listProjects(ctx)
case model.SubjectTasks:
return l.listTasks(ctx)
case model.SubjectCalendars:
return l.listCalendars(ctx)
case model.SubjectEvents:
return l.listEvents(ctx)
case model.SubjectContacts:
return l.listContacts(ctx, nil)
case model.SubjectPersons:
falseVal := false
return l.listContacts(ctx, &falseVal)
case model.SubjectCompanies:
trueVal := true
return l.listContacts(ctx, &trueVal)
case model.SubjectOpportunities:
return l.listOpportunities(ctx)
case model.SubjectCases:
return l.listCases(ctx)
case model.SubjectCRMTasks:
return l.listCRMTasks(ctx)
case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox)
case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent)
case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts)
case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash)
case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam)
case model.SubjectUsers:
return l.listUsers(ctx)
case model.SubjectProjectFiles:
return l.listProjectFiles(ctx)
case model.SubjectTaskFiles:
return nil, fmt.Errorf("select a task in Tasks first (task files need task id)")
default:
return nil, fmt.Errorf("unsupported subject %q", subject)
}
}
// Detail fetches full record data for preview when list row is insufficient.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
switch item.Kind {
case model.KindOpportunity:
return l.Client.GetOpportunity(ctx, item.ID)
case model.KindContact:
return l.Client.GetContact(ctx, item.ID)
case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask:
return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID)
default:
if item.Raw != nil {
return item.Raw, nil
}
return map[string]any{"id": item.ID, "title": item.Title}, nil
}
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects()
if err != nil {
return nil, err
}
items := make([]model.Item, len(projects))
for i, p := range projects {
id := ""
if p.ID != nil {
id = fmt.Sprint(*p.ID)
}
title := ""
if p.Title != nil {
title = *p.Title
}
status := ""
if p.Status != nil {
status = fmt.Sprint(*p.Status)
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
Raw: map[string]any{"id": id, "title": title, "status": status},
}
}
return items, nil
}
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllTasks(ctx, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListCalendars(ctx, "", "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil
}
func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) {
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
rows, err := l.Client.ListEvents(ctx, start, end)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx)
if err != nil {
return nil, err
}
if companyOnly != nil {
filtered := make([]map[string]any, 0, len(rows))
for _, r := range rows {
isCo, _ := r["isCompany"].(bool)
if isCo == *companyOnly {
filtered = append(filtered, r)
}
}
rows = filtered
}
return ItemsFromMaps(rows, model.KindContact, ContactItemFields), nil
}
func (l *Loader) listOpportunities(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindOpportunity, FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "stage"}), nil
}
func (l *Loader) listCases(ctx context.Context) ([]model.Item, error) {
cases, _, err := l.Client.ListCases(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(cases, model.KindCase, FieldMap{IDKey: "id", TitleKey: "title"}), nil
}
func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
rows, _, err := l.Client.ListCRMTasks(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
}
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50})
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
}
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
users, err := l.Client.GetUsers()
if err != nil {
return nil, err
}
items := make([]model.Item, len(users))
for i, u := range users {
id := ""
if u.ID != nil {
id = fmt.Sprint(*u.ID)
}
title := ""
if u.DisplayName != nil {
title = *u.DisplayName
}
email := ""
if u.Email != nil {
email = *u.Email
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
Raw: map[string]any{"id": id, "displayName": title, "email": email},
}
}
return items, nil
}
func (l *Loader) listProjectFiles(ctx context.Context) ([]model.Item, error) {
def := l.Client // need project id from defaults
_ = def
pid := onlyoffice.GetEnvironmentDefaults().ProjectID
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID for project files")
}
resp, err := l.Client.GetProjectFiles(ctx, pid)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range resp.Files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
+75
View File
@@ -0,0 +1,75 @@
package fetch
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// FieldMap names the JSON keys used when building list items from API rows.
type FieldMap struct {
IDKey string
TitleKey string
SubtitleKey string
}
// TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ContactItemFields is the default field map for CRM contacts.
var ContactItemFields = FieldMap{IDKey: "id", TitleKey: "displayName", SubtitleKey: "primaryEmail"}
// MailItemFields is the default field map for mail messages.
var MailItemFields = FieldMap{IDKey: "id", TitleKey: "subject", SubtitleKey: "from"}
// ItemsFromMaps converts OnlyOffice list rows into TUI items.
func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []model.Item {
out := make([]model.Item, len(rows))
for i, row := range rows {
title := str(row, fields.TitleKey)
if title == "" {
title = str(row, "title")
}
if title == "" {
title = str(row, "name")
}
raw := row
out[i] = model.Item{
ID: idStr(row, fields.IDKey),
Title: title,
Subtitle: str(row, fields.SubtitleKey),
Kind: kind,
Raw: raw,
}
}
return out
}
func idStr(m map[string]any, key string) string {
if key == "" {
key = "id"
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprint(m[key])
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
+28
View File
@@ -0,0 +1,28 @@
package fetch_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestItemsFromMaps(t *testing.T) {
rows := []map[string]any{
{"id": float64(1), "title": "Alpha", "status": "Open"},
{"id": float64(2), "title": "Beta", "status": "Closed"},
}
items := fetch.ItemsFromMaps(rows, model.KindTask, fetch.TaskItemFields)
if len(items) != 2 {
t.Fatalf("len=%d", len(items))
}
if items[0].ID != "1" || items[0].Title != "Alpha" {
t.Fatalf("item0=%+v", items[0])
}
if items[0].Kind != model.KindTask {
t.Fatalf("kind=%v", items[0].Kind)
}
if items[1].Subtitle != "Closed" {
t.Fatalf("subtitle=%q", items[1].Subtitle)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Command office is a terminal UI for OnlyOffice Workspace.
//
// Three-pane layout: module tree (left), selectable list (center),
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
package main
import (
"context"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func main() {
client, err := bootstrap.NewClient(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+114
View File
@@ -0,0 +1,114 @@
package model
// Kind identifies the OnlyOffice entity type for list items and preview routing.
type Kind string
const (
KindProject Kind = "project"
KindTask Kind = "task"
KindContact Kind = "contact"
KindOpportunity Kind = "opportunity"
KindCase Kind = "case"
KindCRMTask Kind = "crm_task"
KindMail Kind = "mail"
KindEvent Kind = "event"
KindCalendar Kind = "calendar"
KindFile Kind = "file"
KindUser Kind = "user"
)
// Subject identifies a menu leaf / list source.
type Subject string
const (
SubjectProjects Subject = "projects"
SubjectTasks Subject = "tasks"
SubjectCalendars Subject = "calendars"
SubjectEvents Subject = "events"
SubjectContacts Subject = "contacts"
SubjectPersons Subject = "persons"
SubjectCompanies Subject = "companies"
SubjectOpportunities Subject = "opportunities"
SubjectCases Subject = "cases"
SubjectCRMTasks Subject = "crm_tasks"
SubjectMailInbox Subject = "mail_inbox"
SubjectMailSent Subject = "mail_sent"
SubjectMailDrafts Subject = "mail_drafts"
SubjectMailTrash Subject = "mail_trash"
SubjectMailSpam Subject = "mail_spam"
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
)
// FocusPane is which column has keyboard focus.
type FocusPane int
const (
FocusMenu FocusPane = iota
FocusList
FocusPreview
)
// NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusList
case FocusList:
return FocusPreview
default:
return FocusMenu
}
}
// Item is one row in the center list pane.
type Item struct {
ID, Title, Subtitle string
Kind Kind
Raw map[string]any
Selected bool
}
// Selection tracks multi-selected item IDs within the current subject.
type Selection struct {
ids map[string]struct{}
}
// NewSelection returns an empty selection set.
func NewSelection() *Selection {
return &Selection{ids: make(map[string]struct{})}
}
// Toggle flips selection on items[idx] and updates the ID set.
func (s *Selection) Toggle(items *[]Item, idx int) {
if idx < 0 || idx >= len(*items) {
return
}
it := &(*items)[idx]
it.Selected = !it.Selected
if it.Selected {
s.ids[it.ID] = struct{}{}
} else {
delete(s.ids, it.ID)
}
}
// Count returns the number of selected items.
func (s *Selection) Count() int {
return len(s.ids)
}
// Clear removes all selections.
func (s *Selection) Clear() {
s.ids = make(map[string]struct{})
}
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
func (s *Selection) IDs() []string {
out := make([]string, 0, len(s.ids))
for id := range s.ids {
out = append(out, id)
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestSelectionToggle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "A"},
{ID: "2", Title: "B"},
}
sel := model.NewSelection()
if sel.Count() != 0 {
t.Fatalf("initial count=%d", sel.Count())
}
sel.Toggle(&items, 0)
if !items[0].Selected || sel.Count() != 1 {
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
}
sel.Toggle(&items, 0)
if items[0].Selected || sel.Count() != 0 {
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
}
}
func TestSelectionClearOnSubjectChange(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
sel := model.NewSelection()
sel.Toggle(&items, 0)
sel.Clear()
if sel.Count() != 0 {
t.Fatalf("count=%d after clear", sel.Count())
}
}
func TestNextFocusPane(t *testing.T) {
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
t.Fatalf("menu->list got %v", got)
}
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
t.Fatalf("preview->menu got %v", got)
}
}
+179
View File
@@ -0,0 +1,179 @@
package model
// menuNode is one row in the left navigation tree (flat slice with depth).
type menuNode struct {
label string
depth int
subject Subject // zero when branch node
expandable bool
parent int // index of parent branch, -1 for roots
}
// MenuTree is the left-pane OnlyOffice module navigator.
type MenuTree struct {
nodes []menuNode
cursor int
expanded map[int]bool
visible []int // indices into nodes, recomputed on expand/collapse
}
// DefaultMenuTree returns the standard OnlyOffice Workspace module tree.
func DefaultMenuTree() *MenuTree {
nodes := []menuNode{
{label: "Projects", depth: 0, expandable: true, parent: -1},
{label: "All projects", depth: 1, subject: SubjectProjects, parent: 0},
{label: "Tasks", depth: 1, subject: SubjectTasks, parent: 0},
{label: "Calendar", depth: 0, expandable: true, parent: -1},
{label: "Calendars", depth: 1, subject: SubjectCalendars, parent: 3},
{label: "Events", depth: 1, subject: SubjectEvents, parent: 3},
{label: "CRM", depth: 0, expandable: true, parent: -1},
{label: "Contacts", depth: 1, subject: SubjectContacts, parent: 6},
{label: "Persons", depth: 1, subject: SubjectPersons, parent: 6},
{label: "Companies", depth: 1, subject: SubjectCompanies, parent: 6},
{label: "Opportunities", depth: 1, subject: SubjectOpportunities, parent: 6},
{label: "Cases", depth: 1, subject: SubjectCases, parent: 6},
{label: "CRM Tasks", depth: 1, subject: SubjectCRMTasks, parent: 6},
{label: "Mail", depth: 0, expandable: true, parent: -1},
{label: "Inbox", depth: 1, subject: SubjectMailInbox, parent: 13},
{label: "Sent", depth: 1, subject: SubjectMailSent, parent: 13},
{label: "Drafts", depth: 1, subject: SubjectMailDrafts, parent: 13},
{label: "Trash", depth: 1, subject: SubjectMailTrash, parent: 13},
{label: "Spam", depth: 1, subject: SubjectMailSpam, parent: 13},
{label: "Documents", depth: 0, expandable: true, parent: -1},
{label: "Project files", depth: 1, subject: SubjectProjectFiles, parent: 19},
{label: "Task files", depth: 1, subject: SubjectTaskFiles, parent: 19},
{label: "Users", depth: 0, expandable: true, parent: -1},
{label: "Directory", depth: 1, subject: SubjectUsers, parent: 22},
}
m := &MenuTree{
nodes: nodes,
expanded: map[int]bool{},
}
m.rebuildVisible()
return m
}
// RootLabels returns top-level module names in order.
func (m *MenuTree) RootLabels() []string {
var out []string
for i, n := range m.nodes {
if n.depth == 0 {
out = append(out, n.label)
}
_ = i
}
return out
}
// Cursor returns the visible-row cursor index.
func (m *MenuTree) Cursor() int { return m.cursor }
// SetCursor sets the visible-row cursor.
func (m *MenuTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(m.visible) {
c = len(m.visible) - 1
}
if c < 0 {
c = 0
}
m.cursor = c
}
// MoveDown advances the menu cursor.
func (m *MenuTree) MoveDown() {
m.SetCursor(m.cursor + 1)
}
// MoveUp moves the menu cursor up.
func (m *MenuTree) MoveUp() {
m.SetCursor(m.cursor - 1)
}
// VisibleCount returns the number of visible menu rows.
func (m *MenuTree) VisibleCount() int { return len(m.visible) }
// LabelAt returns the label and depth for visible row i.
func (m *MenuTree) LabelAt(i int) (label string, depth int) {
if i < 0 || i >= len(m.visible) {
return "", 0
}
n := m.nodes[m.visible[i]]
return n.label, n.depth
}
// IsExpandable reports whether visible row i is a branch node.
func (m *MenuTree) IsExpandable(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.nodes[m.visible[visibleIdx]].expandable
}
// IsExpanded reports whether the branch at visible row i is expanded.
func (m *MenuTree) IsExpanded(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.expanded[m.visible[visibleIdx]]
}
// ToggleExpand expands or collapses the branch at visible row i.
func (m *MenuTree) ToggleExpand(visibleIdx int) {
if !m.IsExpandable(visibleIdx) {
return
}
idx := m.visible[visibleIdx]
if m.expanded[idx] {
delete(m.expanded, idx)
} else {
m.expanded[idx] = true
}
m.rebuildVisible()
}
// SelectIndex returns the subject for visible row i when it is a leaf.
func (m *MenuTree) SelectIndex(visibleIdx int) (Subject, bool) {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return "", false
}
n := m.nodes[m.visible[visibleIdx]]
if n.subject == "" {
return "", false
}
return n.subject, true
}
// CurrentSubject returns the subject at the current cursor if it is a leaf.
func (m *MenuTree) CurrentSubject() (Subject, bool) {
return m.SelectIndex(m.cursor)
}
func (m *MenuTree) rebuildVisible() {
m.visible = m.visible[:0]
for i, n := range m.nodes {
if n.depth == 0 {
m.visible = append(m.visible, i)
if m.expanded[i] {
m.appendChildren(i)
}
continue
}
}
if m.cursor >= len(m.visible) {
m.cursor = len(m.visible) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *MenuTree) appendChildren(branchIdx int) {
for i, n := range m.nodes {
if n.parent == branchIdx {
m.visible = append(m.visible, i)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestMenuTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultMenuTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Documents", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestMenuExpandCollapse(t *testing.T) {
tree := model.DefaultMenuTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
}
}
func TestMenuSelectLeafReturnsSubject(t *testing.T) {
tree := model.DefaultMenuTree()
// Expand Projects and select "All projects" (first child).
tree.ToggleExpand(0)
subj, ok := tree.SelectIndex(1)
if !ok {
t.Fatal("expected leaf subject")
}
if subj != model.SubjectProjects {
t.Fatalf("subject=%v want Projects", subj)
}
}
+10
View File
@@ -0,0 +1,10 @@
package preview
import (
md "github.com/JohannesKaufmann/html-to-markdown/v2"
)
// HTMLToMarkdown converts Document Server HTML output to markdown for TUI preview.
func HTMLToMarkdown(html string) (string, error) {
return md.ConvertString(html)
}
+19
View File
@@ -0,0 +1,19 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestHTMLToMarkdown(t *testing.T) {
html := `<h1>Title</h1><p>Body text</p>`
md, err := preview.HTMLToMarkdown(html)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "Title") || !strings.Contains(md, "Body text") {
t.Fatalf("conversion failed: %q", md)
}
}
+168
View File
@@ -0,0 +1,168 @@
package preview
import (
"fmt"
"html"
"regexp"
"strings"
)
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// ContactMarkdown formats a CRM contact or company for preview.
func ContactMarkdown(m map[string]any) string {
name := str(m, "displayName")
if name == "" {
name = strings.TrimSpace(str(m, "firstName") + " " + str(m, "lastName"))
}
kind := "Person"
if boolVal(m, "isCompany") {
kind = "Company"
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", name)
fmt.Fprintf(&b, "**Type:** %s\n\n", kind)
if about := str(m, "about"); about != "" {
fmt.Fprintf(&b, "## About\n\n%s\n\n", about)
}
appendInfoList(&b, m)
return strings.TrimSpace(b.String()) + "\n"
}
// OpportunityMarkdown formats a CRM deal for preview.
func OpportunityMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if v := m["bidValue"]; v != nil {
fmt.Fprintf(&b, "**Value:** %v %s\n\n", v, str(m, "bidCurrency"))
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// MailMarkdown formats a mail message for preview.
func MailMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from"))
if to := str(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to)
}
body := str(m, "body")
if body == "" {
body = str(m, "htmlBody")
}
body = stripHTML(body)
if body != "" {
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
fmt.Fprintf(&b, "**Start:** %s\n\n", str(m, "start"))
fmt.Fprintf(&b, "**End:** %s\n\n", str(m, "end"))
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// TaskMarkdown formats a project or CRM task for preview.
func TaskMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "deadline"); d != "" {
fmt.Fprintf(&b, "**Deadline:** %s\n\n", d)
}
if desc := str(m, "description"); desc != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", desc)
}
return strings.TrimSpace(b.String()) + "\n"
}
// ProjectMarkdown formats a project for preview.
func ProjectMarkdown(m map[string]any) string {
var b strings.Builder
title := str(m, "title")
if title == "" {
title = str(m, "name")
}
fmt.Fprintf(&b, "# %s\n\n", title)
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EntityMarkdown picks a formatter based on item kind.
func EntityMarkdown(kind string, m map[string]any) string {
switch kind {
case "contact", "person", "company":
return ContactMarkdown(m)
case "opportunity":
return OpportunityMarkdown(m)
case "mail":
return MailMarkdown(m)
case "event":
return EventMarkdown(m)
case "task", "crm_task":
return TaskMarkdown(m)
case "project":
return ProjectMarkdown(m)
default:
return mapToMarkdown(m)
}
}
func mapToMarkdown(m map[string]any) string {
var b strings.Builder
b.WriteString("# Details\n\n")
for k, v := range m {
fmt.Fprintf(&b, "- **%s:** %v\n", k, v)
}
return b.String()
}
func appendInfoList(b *strings.Builder, m map[string]any) {
if infos, ok := m["contactInfos"].([]any); ok && len(infos) > 0 {
b.WriteString("## Contact info\n\n")
for _, raw := range infos {
if row, ok := raw.(map[string]any); ok {
fmt.Fprintf(b, "- %s: %s\n", str(row, "infoType"), str(row, "data"))
}
}
b.WriteString("\n")
}
}
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
func boolVal(m map[string]any, key string) bool {
v, ok := m[key].(bool)
return ok && v
}
+89
View File
@@ -0,0 +1,89 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestContactMarkdownCompany(t *testing.T) {
md := preview.ContactMarkdown(map[string]any{
"displayName": "Acme GmbH",
"isCompany": true,
"about": "Widget supplier",
})
if !strings.Contains(md, "Acme GmbH") {
t.Fatalf("title missing: %q", md)
}
if !strings.Contains(md, "Company") {
t.Fatalf("type label missing: %q", md)
}
if !strings.Contains(md, "Widget supplier") {
t.Fatalf("about missing: %q", md)
}
}
func TestOpportunityMarkdown(t *testing.T) {
md := preview.OpportunityMarkdown(map[string]any{
"title": "Big Deal",
"description": "Annual contract",
"bidValue": float64(50000),
"bidCurrency": "EUR",
})
if !strings.Contains(md, "Big Deal") {
t.Fatalf("title missing: %q", md)
}
if !strings.Contains(md, "50000") || !strings.Contains(md, "EUR") {
t.Fatalf("bid missing: %q", md)
}
}
func TestMailMarkdown(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Hello",
"from": "Alice <alice@example.com>",
"body": "<p>Hi there</p>",
})
if !strings.Contains(md, "Hello") {
t.Fatalf("subject missing: %q", md)
}
if !strings.Contains(md, "alice@example.com") {
t.Fatalf("from missing: %q", md)
}
if strings.Contains(md, "<p>") {
t.Fatalf("html not stripped: %q", md)
}
if !strings.Contains(md, "Hi there") {
t.Fatalf("body text missing: %q", md)
}
}
func TestEventMarkdown(t *testing.T) {
md := preview.EventMarkdown(map[string]any{
"title": "Standup",
"start": "2026-06-24T09:00:00",
"end": "2026-06-24T09:15:00",
})
if !strings.Contains(md, "Standup") {
t.Fatalf("title missing: %q", md)
}
if !strings.Contains(md, "2026-06-24") {
t.Fatalf("dates missing: %q", md)
}
}
func TestTaskMarkdown(t *testing.T) {
md := preview.TaskMarkdown(map[string]any{
"title": "Deploy",
"status": "Open",
"deadline": "2026-07-01",
"description": "Roll out v2",
})
if !strings.Contains(md, "Deploy") {
t.Fatalf("title missing: %q", md)
}
if !strings.Contains(md, "Open") {
t.Fatalf("status missing: %q", md)
}
}
+23
View File
@@ -0,0 +1,23 @@
package preview
import (
"github.com/charmbracelet/glamour"
)
// RenderMarkdown renders GitHub-flavoured markdown for terminal display.
func RenderMarkdown(md string, width int) (string, error) {
if md == "" {
return "", nil
}
if width < 20 {
width = 20
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return "", err
}
return r.Render(md)
}
+31
View File
@@ -0,0 +1,31 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestRenderMarkdown(t *testing.T) {
out, err := preview.RenderMarkdown("# Hello\n\nWorld", 40)
if err != nil {
t.Fatal(err)
}
if out == "" {
t.Fatal("empty render output")
}
if !strings.Contains(strings.ToLower(out), "hello") {
t.Fatalf("expected rendered heading: %q", out)
}
}
func TestRenderMarkdownEmpty(t *testing.T) {
out, err := preview.RenderMarkdown("", 40)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("expected empty, got %q", out)
}
}
+65
View File
@@ -0,0 +1,65 @@
package preview
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"strings"
xls "github.com/eslider/go-xls/v2"
)
// CSVToMarkdownTable parses CSV bytes into a GitHub pipe table markdown string.
func CSVToMarkdownTable(data []byte) (string, error) {
tab, err := parseCSV(data)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := xls.WriteMarkdownTable(&buf, tab); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
func parseCSV(data []byte) (xls.Table, error) {
r := csv.NewReader(bytes.NewReader(data))
r.TrimLeadingSpace = true
records, err := r.ReadAll()
if err != nil {
return xls.Table{}, err
}
if len(records) == 0 {
return xls.Table{}, fmt.Errorf("preview: empty csv")
}
return xls.Table{Columns: records[0], Rows: records[1:]}, nil
}
// JSONToMarkdown wraps JSON in a fenced code block for preview.
func JSONToMarkdown(data []byte) (string, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return "", err
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("```json\n%s\n```\n", string(pretty)), nil
}
// FileBytesToMarkdown picks a preview format based on file extension.
func FileBytesToMarkdown(name string, data []byte) (string, error) {
ext := strings.ToLower(name)
switch {
case strings.HasSuffix(ext, ".csv"):
return CSVToMarkdownTable(data)
case strings.HasSuffix(ext, ".json"):
return JSONToMarkdown(data)
case strings.HasSuffix(ext, ".md"), strings.HasSuffix(ext, ".txt"):
return string(data), nil
default:
return fmt.Sprintf("```\n(binary or unsupported preview for %s, %d bytes)\n```\n", name, len(data)), nil
}
}
+32
View File
@@ -0,0 +1,32 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestCSVToMarkdownTable(t *testing.T) {
csv := "name,score\nAlice,10\nBob,20\n"
md, err := preview.CSVToMarkdownTable([]byte(csv))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "name") || !strings.Contains(md, "Alice") {
t.Fatalf("table missing data: %q", md)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
func TestJSONToMarkdown(t *testing.T) {
md, err := preview.JSONToMarkdown([]byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "```") || !strings.Contains(md, `"a"`) {
t.Fatalf("expected fenced json: %q", md)
}
}
+339
View File
@@ -0,0 +1,339 @@
package ui
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
type listLoadedMsg struct {
items []model.Item
subject model.Subject
err error
}
type previewLoadedMsg struct {
text string
err error
}
// Model is the root Bubble Tea model for the office TUI.
type Model struct {
client *onlyoffice.Client
loader *fetch.Loader
menu *model.MenuTree
items []model.Item
selection *model.Selection
subject model.Subject
listIdx int
focus model.FocusPane
width int
height int
status string
err string
loading bool
preview viewport.Model
previewMD string
ready bool
}
// NewModel constructs the TUI with an authenticated client.
func NewModel(client *onlyoffice.Client) Model {
vp := viewport.New(40, 20)
return Model{
client: client,
loader: &fetch.Loader{Client: client},
menu: model.DefaultMenuTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
preview: vp,
status: "office — Tab: pane Space: select Enter: preview q: quit",
}
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.layoutViewports()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "?" {
m.status = helpText()
return m, nil
}
action := KeyAction(key, m.focus)
if action == ActionNone && (key == "up" || key == "k") {
action = ResolveMoveUp(m.focus)
}
switch action {
case ActionQuit:
return m, tea.Quit
case ActionNextPane:
m.focus = model.NextFocusPane(m.focus)
return m, nil
case ActionMoveUp:
m.moveUp()
return m, nil
case ActionMoveDown:
m.moveDown()
return m, nil
case ActionToggleSelect:
m.selection.Toggle(&m.items, m.listIdx)
return m, nil
case ActionOpenPreview:
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
case ActionRefresh:
m.loading = true
return m, m.loadListCmd(m.subject)
case ActionOpenVex:
return m, m.openVexCmd()
}
if m.focus == model.FocusMenu && (key == "enter" || key == " ") {
if subj, ok := m.menu.CurrentSubject(); ok {
m.subject = subj
m.selection.Clear()
m.listIdx = 0
m.focus = model.FocusList
m.loading = true
return m, m.loadListCmd(subj)
}
if m.menu.IsExpandable(m.menu.Cursor()) {
m.menu.ToggleExpand(m.menu.Cursor())
}
return m, nil
}
case listLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
return m, nil
}
m.err = ""
m.items = msg.items
m.subject = msg.subject
return m, nil
case previewLoadedMsg:
m.loading = false
if msg.err != nil {
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
} else {
m.previewMD = msg.text
}
m.renderPreview()
return m, nil
}
var cmd tea.Cmd
if m.focus == model.FocusPreview {
m.preview, cmd = m.preview.Update(msg)
}
return m, cmd
}
// View implements tea.Model.
func (m Model) View() string {
if m.width == 0 {
return "Loading…\n"
}
menuW, listW, prevW := LayoutWidths(m.width)
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2)
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2)
status := m.status
if m.loading {
status = "Loading…"
}
if m.err != "" {
status = "Error: " + m.err
}
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
return lipgloss.JoinHorizontal(lipgloss.Top,
menuStyle.Render(m.renderMenu(menuW)),
listStyle.Render(m.renderList(listW)),
prevStyle.Render(m.preview.View()),
) + "\n" + bar
}
func (m *Model) layoutViewports() {
_, _, prevW := LayoutWidths(m.width)
h := m.height - 4
if h < 4 {
h = 4
}
m.preview.Width = prevW - 2
m.preview.Height = h
m.renderPreview()
}
func (m *Model) renderPreview() {
_, _, prevW := LayoutWidths(m.width)
text, err := preview.RenderMarkdown(m.previewMD, prevW-4)
if err != nil {
text = m.previewMD
}
m.preview.SetContent(text)
}
func (m *Model) renderMenu(w int) string {
var b strings.Builder
b.WriteString("Modules\n\n")
for i := 0; i < m.menu.VisibleCount(); i++ {
label, depth := m.menu.LabelAt(i)
prefix := strings.Repeat(" ", depth)
line := prefix + label
if m.menu.IsExpandable(i) {
if m.menu.IsExpanded(i) {
line = prefix + "▾ " + label
} else {
line = prefix + "▸ " + label
}
}
if i == m.menu.Cursor() && m.focus == model.FocusMenu {
line = "> " + line
} else {
line = " " + line
}
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n")
}
return b.String()
}
func (m *Model) renderList(w int) string {
title := string(m.subject)
if title == "" {
title = "Items"
}
var b strings.Builder
fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items))
for i, it := range m.items {
mark := "[ ]"
if it.Selected {
mark = "[x]"
}
cursor := " "
if i == m.listIdx && m.focus == model.FocusList {
cursor = "> "
}
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
if it.Subtitle != "" {
line += " — " + it.Subtitle
}
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n")
}
if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n")
}
return b.String()
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.menu.MoveUp()
case model.FocusList:
if m.listIdx > 0 {
m.listIdx--
}
case model.FocusPreview:
m.preview.LineUp(1)
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.menu.MoveDown()
case model.FocusList:
if m.listIdx < len(m.items)-1 {
m.listIdx++
}
case model.FocusPreview:
m.preview.LineDown(1)
}
}
func (m *Model) loadListCmd(subject model.Subject) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.List(ctx, subject)
return listLoadedMsg{items: items, subject: subject, err: err}
}
}
func (m *Model) loadPreviewCmd() tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return nil
}
item := m.items[m.listIdx]
return func() tea.Msg {
ctx := context.Background()
raw, err := m.loader.Detail(ctx, item)
if err != nil {
return previewLoadedMsg{err: err}
}
md := preview.EntityMarkdown(string(item.Kind), raw)
return previewLoadedMsg{text: md}
}
}
func (m *Model) openVexCmd() tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return nil
}
item := m.items[m.listIdx]
if item.Kind != model.KindFile {
return func() tea.Msg {
return previewLoadedMsg{text: "_Press v on a spreadsheet file row._\n"}
}
}
return nil
}
func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if focused {
return s.Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62"))
}
return s.Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("238"))
}
func helpText() string {
return "Tab: pane | j/k: move | Space: select | Enter: preview | r: refresh | v: vex | q: quit"
}
// OpenVex launches the vex-tui binary for a file path when available.
func OpenVex(path string) error {
if _, err := exec.LookPath("vex"); err != nil {
return fmt.Errorf("vex not found on PATH")
}
cmd := exec.Command("vex", path)
return cmd.Run()
}
+42
View File
@@ -0,0 +1,42 @@
package ui_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestKeyActionMenuDown(t *testing.T) {
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
t.Fatalf("got %v", got)
}
}
func TestKeyActionListToggleSelect(t *testing.T) {
if got := ui.KeyAction(" ", model.FocusList); got != ui.ActionToggleSelect {
t.Fatalf("got %v", got)
}
}
func TestKeyActionPreviewNoOp(t *testing.T) {
if got := ui.KeyAction(" ", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("got %v", got)
}
}
func TestKeyActionTabNextPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidths(120)
if menu+list+preview > 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
}
if menu < 20 || list < 30 || preview < 30 {
t.Fatalf("widths too narrow: %d %d %d", menu, list, preview)
}
}
+77
View File
@@ -0,0 +1,77 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// Action is a keyboard command outcome for the TUI.
type Action int
const (
ActionNone Action = iota
ActionMoveUp
ActionMoveDown
ActionToggleSelect
ActionOpenPreview
ActionNextPane
ActionRefresh
ActionQuit
ActionOpenVex
)
// KeyAction maps a key string and focused pane to an action.
func KeyAction(key string, pane model.FocusPane) Action {
switch key {
case "q", "ctrl+c":
return ActionQuit
case "tab":
return ActionNextPane
case "r":
return ActionRefresh
case "up", "k":
return ActionMoveUp
case "down", "j":
return ActionMoveDown
case " ":
if pane == model.FocusList {
return ActionToggleSelect
}
case "enter":
if pane == model.FocusList {
return ActionOpenPreview
}
case "v":
if pane == model.FocusList {
return ActionOpenVex
}
}
return ActionNone
}
// LayoutWidths splits total terminal width into menu, list, preview columns.
func LayoutWidths(total int) (menu, list, preview int) {
if total < 80 {
total = 80
}
menu = total / 5
if menu < 22 {
menu = 22
}
preview = total / 3
if preview < 28 {
preview = 28
}
list = total - menu - preview - 2
if list < 24 {
list = 24
}
return menu, list, preview
}
// ResolveMoveUp returns the action for upward navigation keys.
func ResolveMoveUp(pane model.FocusPane) Action {
switch pane {
case model.FocusMenu, model.FocusList, model.FocusPreview:
return ActionMoveUp
default:
return ActionNone
}
}
+2 -32
View File
@@ -9,7 +9,7 @@ import (
"text/tabwriter" "text/tabwriter"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv" "github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -36,37 +36,7 @@ func execute() error { return rootCmd.Execute() }
// newOO loads env (only .env in CWD) and returns an authenticated client. // newOO loads env (only .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles. // godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
loadOOEnv() return bootstrap.NewClient(cmd.Context())
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(cmd.Context()); err != nil {
return nil, err
}
return c, nil
}
func loadOOEnv() {
_ = godotenv.Load(".env")
applyOOEnvAliases()
}
func applyOOEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
} }
// printJSON dumps any value as indented JSON. // printJSON dumps any value as indented JSON.
-40
View File
@@ -8,46 +8,6 @@ import (
"testing" "testing"
) )
func TestLoadOOEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.produktor.io\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
loadOOEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.produktor.io" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestApplyOOEnvAliasesDoesNotOverrideCanonical(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
applyOOEnvAliases()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestFmtCell(t *testing.T) { func TestFmtCell(t *testing.T) {
tests := []struct { tests := []struct {
in any in any
+32 -1
View File
@@ -1,14 +1,45 @@
module github.com/eslider/go-onlyoffice module github.com/eslider/go-onlyoffice
go 1.22.2 go 1.25.0
require ( require (
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v0.12.1
github.com/eslider/go-xls/v2 v2.1.0
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )
require ( require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
) )
+83
View File
@@ -1,16 +1,99 @@
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4 h1:6KzMkQeAF56rggw2NZu1L+TH7j9+DM1/2Kmh7KUxg1I=
github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=