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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.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) {
|
||||
tests := []struct {
|
||||
in any
|
||||
|
||||
Reference in New Issue
Block a user