diff --git a/AGENTS.md b/AGENTS.md index 70a7d45..bfbbce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,9 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea): - `main.go` — entry point (docstring lists the command tree). - `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag. - - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`. + - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go`, `catalog.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. +- **Catalog inventory — `catalog/` package.** Filesystem VCF/project scan → YAML → match/apply against CRM. Used by `oo catalog`; not part of the flat `*Client` surface (uses Client as a dependency). - **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/`. - **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables. - **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it. diff --git a/README.md b/README.md index 5e6db65..e108f6f 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,7 @@ oo tasks files detach 208 12345 | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories` | | `applications` | `sync` | +| `catalog` | `scan-contacts`, `scan-projects`, `merge`, `match`, `apply` | The CLI reads only `.env` from the current working directory (godotenv is a CLI-only concern — the library itself never loads dotfiles). @@ -733,6 +734,26 @@ oo contacts get 857 -o json oo crm cleanup -o json ``` +### Clients/contacts inventory (`catalog`) + +**Problem:** Contacts live as VCF/folders on disk and project trees under +`~/work` / ops-host; OnlyOffice CRM is the SSOT for persons/companies but +starts sparse. You need a reviewable inventory before writes. + +```bash +oo catalog scan-contacts --root /path/to/contacts -O /tmp/contacts.yaml +oo catalog scan-projects --root ~/work -O /tmp/projects.yaml +oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -O clients-contacts.yaml +oo catalog match -i clients-contacts.yaml +# set approve: true on pilot rows in the YAML +oo catalog apply --dry-run -i clients-contacts.yaml +oo catalog apply --apply -i clients-contacts.yaml +oo crm cleanup +``` + +YAML schema and ops runbook live in the inventar repo under `docs/catalog/` and +`docs/ops/oo-clients-contacts-sync.md`. Library helper: `FindPersonByEmail`. + ### Job applications → CRM (`applications sync`) **Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`) diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go index 4fa8ed8..9f5eca9 100644 --- a/cmd/oo/cli_test.go +++ b/cmd/oo/cli_test.go @@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) { want := []string{ "calendar", "projects", "tasks", "users", "whoami", "contacts", "persons", "companies", - "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", + "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog", } got := make(map[string]bool, len(rootCmd.Commands())) for _, c := range rootCmd.Commands() { diff --git a/cmd/oo/common.go b/cmd/oo/common.go index 2de7730..5f5ab27 100644 --- a/cmd/oo/common.go +++ b/cmd/oo/common.go @@ -20,7 +20,7 @@ var outputFormat = "table" var rootCmd = &cobra.Command{ Use: "oo", Short: "OnlyOffice Workspace CLI — subject-based command tree", - Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).", + Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog).", Version: version, SilenceUsage: true, SilenceErrors: false, diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 778135a..6dd3856 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -15,6 +15,7 @@ // oo crm cleanup // oo mails accounts | folders | list | get | delete // oo applications sync +// oo catalog scan-contacts | scan-projects | merge | match | apply // // Every list supports `--output/-o json|table` (table is the default). // diff --git a/crm.go b/crm.go index ea1cd76..0807c6b 100644 --- a/crm.go +++ b/crm.go @@ -96,6 +96,53 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string return nil, nil } +// FindPersonByEmail finds a person whose primary or contact-info email matches +// (case-insensitive). Companies are skipped. Returns nil when not found. +func (c *Client) FindPersonByEmail(ctx context.Context, email string) (map[string]any, error) { + needle := strings.ToLower(strings.TrimSpace(email)) + if needle == "" { + return nil, nil + } + all, err := c.ListAllContacts(ctx) + if err != nil { + return nil, err + } + for _, p := range all { + if isCompany(p) { + continue + } + for _, em := range contactEmailValues(p) { + if strings.ToLower(strings.TrimSpace(em)) == needle { + return p, nil + } + } + } + return nil, nil +} + +func contactEmailValues(p map[string]any) []string { + var out []string + for _, key := range []string{"email", "primaryEmail"} { + if v := strings.TrimSpace(fmt.Sprint(p[key])); v != "" && v != "" { + out = append(out, v) + } + } + for _, row := range ContactInfoRows(p) { + t := strings.ToLower(fmt.Sprint(row["infoType"])) + if t != "email" { + continue + } + data := strings.TrimSpace(fmt.Sprint(row["data"])) + if data == "" || data == "" { + data = strings.TrimSpace(fmt.Sprint(row["value"])) + } + if data != "" { + out = append(out, data) + } + } + return out +} + func isCompany(m map[string]any) bool { v, ok := m["isCompany"].(bool) return ok && v diff --git a/go.mod b/go.mod index 4c7b75e..1ab28e8 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,14 @@ require ( github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v0.12.1 github.com/charmbracelet/x/ansi v0.1.4 + github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 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/mattn/go-runewidth v0.0.15 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index e733ad8..165c3d5 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:Yyn 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/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI= +github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= 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= @@ -98,4 +100,7 @@ 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=