From f8a73edef3de6cddd6d0fb29902c76a2f3e145a0 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 24 Jun 2026 16:29:04 +0100 Subject: [PATCH] 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 --- .env.example | 4 + .github/workflows/test.yml | 3 + .goreleaser.yml | 33 +- AGENTS.md | 2 + CHANGELOG.md | 11 + README.md | 266 ++++++++++++++-- cmd/internal/bootstrap/bootstrap.go | 50 +++ cmd/internal/bootstrap/bootstrap_test.go | 95 ++++++ cmd/office/fetch/fetch_integration_test.go | 45 +++ cmd/office/fetch/list.go | 243 +++++++++++++++ cmd/office/fetch/map.go | 75 +++++ cmd/office/fetch/map_test.go | 28 ++ cmd/office/main.go | 34 +++ cmd/office/model/item.go | 114 +++++++ cmd/office/model/item_test.go | 45 +++ cmd/office/model/menu.go | 179 +++++++++++ cmd/office/model/menu_test.go | 49 +++ cmd/office/preview/convert.go | 10 + cmd/office/preview/convert_test.go | 19 ++ cmd/office/preview/entity.go | 168 ++++++++++ cmd/office/preview/entity_test.go | 89 ++++++ cmd/office/preview/render.go | 23 ++ cmd/office/preview/render_test.go | 31 ++ cmd/office/preview/spreadsheet.go | 65 ++++ cmd/office/preview/spreadsheet_test.go | 32 ++ cmd/office/ui/app.go | 339 +++++++++++++++++++++ cmd/office/ui/app_test.go | 42 +++ cmd/office/ui/keys.go | 77 +++++ cmd/oo/common.go | 34 +-- cmd/oo/common_test.go | 40 --- go.mod | 33 +- go.sum | 83 +++++ 32 files changed, 2267 insertions(+), 94 deletions(-) create mode 100644 cmd/internal/bootstrap/bootstrap.go create mode 100644 cmd/internal/bootstrap/bootstrap_test.go create mode 100644 cmd/office/fetch/fetch_integration_test.go create mode 100644 cmd/office/fetch/list.go create mode 100644 cmd/office/fetch/map.go create mode 100644 cmd/office/fetch/map_test.go create mode 100644 cmd/office/main.go create mode 100644 cmd/office/model/item.go create mode 100644 cmd/office/model/item_test.go create mode 100644 cmd/office/model/menu.go create mode 100644 cmd/office/model/menu_test.go create mode 100644 cmd/office/preview/convert.go create mode 100644 cmd/office/preview/convert_test.go create mode 100644 cmd/office/preview/entity.go create mode 100644 cmd/office/preview/entity_test.go create mode 100644 cmd/office/preview/render.go create mode 100644 cmd/office/preview/render_test.go create mode 100644 cmd/office/preview/spreadsheet.go create mode 100644 cmd/office/preview/spreadsheet_test.go create mode 100644 cmd/office/ui/app.go create mode 100644 cmd/office/ui/app_test.go create mode 100644 cmd/office/ui/keys.go diff --git a/.env.example b/.env.example index e018a13..7374358 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ ONLYOFFICE_CALENDAR_ID=1 ONLYOFFICE_PROJECT_ID=33 # 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= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b77be7..77c0345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,5 +42,8 @@ jobs: - name: Build 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 run: go test -race -shuffle=on -count=1 ./... diff --git a/.goreleaser.yml b/.goreleaser.yml index a24edb8..a689710 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -31,6 +31,24 @@ builds: - -X main.version={{.Version}} - -X main.commit={{.Commit}} - -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: - id: oo @@ -43,6 +61,16 @@ archives: - LICENSE - README.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: name_template: checksums.txt @@ -82,14 +110,15 @@ release: prerelease: auto mode: replace header: | - ## `oo` {{ .Tag }} + ## Release {{ .Tag }} Install: ```sh 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: | **Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }} diff --git a/AGENTS.md b/AGENTS.md index 06af717..767b01a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - `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. +- **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. ## Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 2683b09..b633e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 - **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 +- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts). - `oo applications sync` — empty position uses company-only deal title; skips duplicate opportunity members; paginated find-or-create lookups. - `oo` now loads OnlyOffice credentials only from `.env` in the current diff --git a/README.md b/README.md index 9c5954e..65f098d 100644 --- a/README.md +++ b/README.md @@ -536,11 +536,11 @@ company, _ := client.FindCompany(ctx, "ACME") 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 -library lives under `cmd/oo`. The command tree is **subject-based**, mirroring -the [`tea`](https://gitea.com/gitea/tea) CLI: +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 @@ -560,7 +560,48 @@ oo opportunities stages oo cases list oo crm-tasks categories 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) oo projects files list 33 oo projects files upload 33 ./notes.md @@ -606,6 +647,205 @@ Run `oo --help` or `oo --help` for the full command reference. > (`oo calendar events`, `oo tasks list`, `oo contacts list`, > `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 ` @ contoso`, 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 " @ contoso" 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 contoso +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 | Variable | Description | @@ -617,20 +857,7 @@ Run `oo --help` or `oo --help` for the full command reference. | `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` | -### Workspace mail (`oo mails`) - -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). +Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above. ### CI / releases @@ -638,9 +865,9 @@ GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go | 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.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**. @@ -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 | | [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 diff --git a/cmd/internal/bootstrap/bootstrap.go b/cmd/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..9712266 --- /dev/null +++ b/cmd/internal/bootstrap/bootstrap.go @@ -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) + } +} diff --git a/cmd/internal/bootstrap/bootstrap_test.go b/cmd/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..233081d --- /dev/null +++ b/cmd/internal/bootstrap/bootstrap_test.go @@ -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.example.com\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.example.com" { + 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) + }) + } +} diff --git a/cmd/office/fetch/fetch_integration_test.go b/cmd/office/fetch/fetch_integration_test.go new file mode 100644 index 0000000..8177257 --- /dev/null +++ b/cmd/office/fetch/fetch_integration_test.go @@ -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)) +} diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go new file mode 100644 index 0000000..c73ac61 --- /dev/null +++ b/cmd/office/fetch/list.go @@ -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 +} diff --git a/cmd/office/fetch/map.go b/cmd/office/fetch/map.go new file mode 100644 index 0000000..61495d4 --- /dev/null +++ b/cmd/office/fetch/map.go @@ -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]) +} diff --git a/cmd/office/fetch/map_test.go b/cmd/office/fetch/map_test.go new file mode 100644 index 0000000..f03d7ad --- /dev/null +++ b/cmd/office/fetch/map_test.go @@ -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) + } +} diff --git a/cmd/office/main.go b/cmd/office/main.go new file mode 100644 index 0000000..d586802 --- /dev/null +++ b/cmd/office/main.go @@ -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) + } +} diff --git a/cmd/office/model/item.go b/cmd/office/model/item.go new file mode 100644 index 0000000..fed5b51 --- /dev/null +++ b/cmd/office/model/item.go @@ -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 +} diff --git a/cmd/office/model/item_test.go b/cmd/office/model/item_test.go new file mode 100644 index 0000000..c7ad9e0 --- /dev/null +++ b/cmd/office/model/item_test.go @@ -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) + } +} diff --git a/cmd/office/model/menu.go b/cmd/office/model/menu.go new file mode 100644 index 0000000..0e08ab2 --- /dev/null +++ b/cmd/office/model/menu.go @@ -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) + } + } +} diff --git a/cmd/office/model/menu_test.go b/cmd/office/model/menu_test.go new file mode 100644 index 0000000..7a96ae7 --- /dev/null +++ b/cmd/office/model/menu_test.go @@ -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) + } +} diff --git a/cmd/office/preview/convert.go b/cmd/office/preview/convert.go new file mode 100644 index 0000000..24fb469 --- /dev/null +++ b/cmd/office/preview/convert.go @@ -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) +} diff --git a/cmd/office/preview/convert_test.go b/cmd/office/preview/convert_test.go new file mode 100644 index 0000000..bc52054 --- /dev/null +++ b/cmd/office/preview/convert_test.go @@ -0,0 +1,19 @@ +package preview_test + +import ( + "strings" + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/preview" +) + +func TestHTMLToMarkdown(t *testing.T) { + html := `

Title

Body text

` + 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) + } +} diff --git a/cmd/office/preview/entity.go b/cmd/office/preview/entity.go new file mode 100644 index 0000000..6fdfc87 --- /dev/null +++ b/cmd/office/preview/entity.go @@ -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 +} diff --git a/cmd/office/preview/entity_test.go b/cmd/office/preview/entity_test.go new file mode 100644 index 0000000..9f2fb39 --- /dev/null +++ b/cmd/office/preview/entity_test.go @@ -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 ", + "body": "

Hi there

", + }) + 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, "

") { + 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) + } +} diff --git a/cmd/office/preview/render.go b/cmd/office/preview/render.go new file mode 100644 index 0000000..430304e --- /dev/null +++ b/cmd/office/preview/render.go @@ -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) +} diff --git a/cmd/office/preview/render_test.go b/cmd/office/preview/render_test.go new file mode 100644 index 0000000..10c73b9 --- /dev/null +++ b/cmd/office/preview/render_test.go @@ -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) + } +} diff --git a/cmd/office/preview/spreadsheet.go b/cmd/office/preview/spreadsheet.go new file mode 100644 index 0000000..a6e4488 --- /dev/null +++ b/cmd/office/preview/spreadsheet.go @@ -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 + } +} diff --git a/cmd/office/preview/spreadsheet_test.go b/cmd/office/preview/spreadsheet_test.go new file mode 100644 index 0000000..1c8dbb9 --- /dev/null +++ b/cmd/office/preview/spreadsheet_test.go @@ -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) + } +} diff --git a/cmd/office/ui/app.go b/cmd/office/ui/app.go new file mode 100644 index 0000000..2fd9064 --- /dev/null +++ b/cmd/office/ui/app.go @@ -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() +} diff --git a/cmd/office/ui/app_test.go b/cmd/office/ui/app_test.go new file mode 100644 index 0000000..97cf51c --- /dev/null +++ b/cmd/office/ui/app_test.go @@ -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) + } +} diff --git a/cmd/office/ui/keys.go b/cmd/office/ui/keys.go new file mode 100644 index 0000000..2a9d857 --- /dev/null +++ b/cmd/office/ui/keys.go @@ -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 + } +} diff --git a/cmd/oo/common.go b/cmd/oo/common.go index 18d09fb..2de7730 100644 --- a/cmd/oo/common.go +++ b/cmd/oo/common.go @@ -9,7 +9,7 @@ import ( "text/tabwriter" onlyoffice "github.com/eslider/go-onlyoffice" - "github.com/joho/godotenv" + "github.com/eslider/go-onlyoffice/cmd/internal/bootstrap" "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. // godotenv is a CLI-only concern; the library itself never loads dotfiles. func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { - loadOOEnv() - 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) - } + return bootstrap.NewClient(cmd.Context()) } // printJSON dumps any value as indented JSON. diff --git a/cmd/oo/common_test.go b/cmd/oo/common_test.go index ccf2203..002f5f9 100644 --- a/cmd/oo/common_test.go +++ b/cmd/oo/common_test.go @@ -8,46 +8,6 @@ import ( "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.example.com\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.example.com" { - 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) { tests := []struct { in any diff --git a/go.mod b/go.mod index 682db56..c8fd2ca 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,45 @@ module github.com/eslider/go-onlyoffice -go 1.22.2 +go 1.25.0 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/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 ) 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/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/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 ) diff --git a/go.sum b/go.sum index f1f9b54..7087a1a 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 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/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/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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/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= +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=