feat(oo): catalog scan/match/apply for CRM clients and contacts

Add filesystem inventory (VCF, contact folders, project trees) into a
reviewable YAML catalog, match against live OnlyOffice, and upsert only
rows with approve:true. Includes FindPersonByEmail for email-keyed persons.
This commit is contained in:
2026-07-26 11:10:02 +01:00
parent 36800e319c
commit c32e4dd611
16 changed files with 1379 additions and 3 deletions
+2 -1
View File
@@ -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.
+21
View File
@@ -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
`~/projects` / arc-1; 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 ~/projects -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`)
+187
View File
@@ -0,0 +1,187 @@
package catalog
import (
"context"
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
)
// ApplyResult summarizes one apply pass.
type ApplyResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
DryRun bool `json:"dry_run"`
}
// ApplyApproved creates/updates OO contacts for entries with approve=true.
func ApplyApproved(ctx context.Context, client *onlyoffice.Client, doc *Document, dryRun bool) (*ApplyResult, error) {
res := &ApplyResult{DryRun: dryRun}
for i := range doc.Entries {
e := &doc.Entries[i]
if !e.Approve {
res.Skipped++
continue
}
if e.Status == "conflict" {
res.Skipped++
res.Errors = append(res.Errors, e.ID+": conflict — resolve manually")
continue
}
if dryRun {
if e.OOID != "" || e.Status == "exists" {
res.Updated++
} else {
res.Created++
}
continue
}
created, err := applyOne(ctx, client, e)
if err != nil {
res.Errors = append(res.Errors, e.ID+": "+err.Error())
continue
}
if created {
res.Created++
} else {
res.Updated++
}
}
return res, nil
}
func applyOne(ctx context.Context, client *onlyoffice.Client, e *Entry) (created bool, err error) {
switch e.Kind {
case "company":
return applyCompany(ctx, client, e)
case "person":
return applyPerson(ctx, client, e)
default:
return false, fmt.Errorf("unknown kind %q", e.Kind)
}
}
func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
name := strings.TrimSpace(e.Name)
if name == "" {
return false, fmt.Errorf("company missing name")
}
var co map[string]any
var err error
if e.OOID != "" {
co, err = client.GetContact(ctx, e.OOID)
} else {
co, err = client.FindCompany(ctx, name)
}
if err != nil {
return false, err
}
created := false
if co == nil {
co, err = client.CreateCompany(ctx, name)
if err != nil {
return false, err
}
created = true
}
id := contactIDString(co)
e.OOID = id
if err := ensureContactInfos(ctx, client, id, e); err != nil {
return created, err
}
e.Status = "applied"
if created {
e.Notes = "created"
} else {
e.Notes = "updated"
}
return created, nil
}
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
first := strings.TrimSpace(e.First)
last := strings.TrimSpace(e.Last)
if first == "" && last == "" {
first, last = SplitDisplayName(e.Name)
}
if first == "" {
first = strings.TrimSpace(e.Name)
}
if first == "" {
return false, fmt.Errorf("person missing name")
}
if last == "" {
last = "-"
}
var p map[string]any
var err error
if e.OOID != "" {
p, err = client.GetContact(ctx, e.OOID)
} else if len(e.Emails) > 0 {
p, err = client.FindPersonByEmail(ctx, e.Emails[0])
} else {
p, err = client.FindPerson(ctx, first, last)
}
if err != nil {
return false, err
}
created := false
companyID := 0
if e.Org != "" {
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
companyID, _ = strconv.Atoi(contactIDString(co))
}
}
if p == nil {
about := ""
if e.Org != "" {
about = "org: " + e.Org
}
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
if err != nil {
return false, err
}
created = true
}
id := contactIDString(p)
e.OOID = id
if err := ensureContactInfos(ctx, client, id, e); err != nil {
return created, err
}
e.Status = "applied"
if created {
e.Notes = "created"
} else {
e.Notes = "updated"
}
return created, nil
}
func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactID string, e *Entry) error {
existing, err := client.GetContact(ctx, contactID)
if err != nil {
return err
}
for i, em := range e.Emails {
if onlyoffice.HasContactInfo(existing, "Email", em) {
continue
}
if _, err := client.AddContactInfo(ctx, contactID, "Email", em, "Work", i == 0); err != nil {
return fmt.Errorf("add email %s: %w", em, err)
}
}
for _, ph := range e.Phones {
if onlyoffice.HasContactInfo(existing, "Phone", ph) {
continue
}
if _, err := client.AddContactInfo(ctx, contactID, "Phone", ph, "Work", false); err != nil {
return fmt.Errorf("add phone %s: %w", ph, err)
}
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package catalog
import (
"os"
"path/filepath"
"testing"
)
func TestParseVCFFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.vcf")
body := `BEGIN:VCARD
VERSION:3.0
FN:Ada Lovelace
N:Lovelace;Ada;;;
EMAIL;TYPE=INTERNET:ada@example.com
TEL;TYPE=CELL:+1-555-0100
ORG:Analytical Engines
END:VCARD
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
entries, err := ParseVCFFile(path)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries", len(entries))
}
e := entries[0]
if e.Kind != "person" || e.First != "Ada" || e.Last != "Lovelace" {
t.Fatalf("unexpected entry: %+v", e)
}
if len(e.Emails) != 1 || e.Emails[0] != "ada@example.com" {
t.Fatalf("emails: %v", e.Emails)
}
if e.Org != "Analytical Engines" {
t.Fatalf("org: %q", e.Org)
}
if e.Approve {
t.Fatal("approve should default false")
}
}
func TestMergeDocs(t *testing.T) {
a := &Document{Entries: []Entry{{
ID: "person:ada@example.com", Kind: "person", First: "Ada", Emails: []string{"ada@example.com"},
Sources: []string{"a.vcf"}, Zone: "private", Role: "unknown",
}}}
b := &Document{Entries: []Entry{{
ID: "person:ada@example.com", Kind: "person", Last: "Lovelace", Phones: []string{"+1"},
Sources: []string{"folder/Ada"}, Zone: "warm", Role: "work", Approve: true,
}}}
m := MergeDocs(a, b)
if len(m.Entries) != 1 {
t.Fatalf("len=%d", len(m.Entries))
}
e := m.Entries[0]
if e.First != "Ada" || e.Last != "Lovelace" {
t.Fatalf("%+v", e)
}
if len(e.Sources) != 2 || len(e.Phones) != 1 || !e.Approve {
t.Fatalf("%+v", e)
}
if e.Zone != "warm" || e.Role != "work" {
t.Fatalf("zone/role: %s/%s", e.Zone, e.Role)
}
}
func TestScanContactsRoot(t *testing.T) {
root := t.TempDir()
vcfDir := filepath.Join(root, "Contacts VCF's")
if err := os.MkdirAll(vcfDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vcfDir, "bob.vcf"), []byte(`BEGIN:VCARD
VERSION:3.0
FN:Bob Builder
EMAIL:bob@work.test
END:VCARD
`), 0o644); err != nil {
t.Fatal(err)
}
personDir := filepath.Join(root, "Carol Smith")
if err := os.MkdirAll(personDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(personDir, "carol@example.com.txt"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
doc, err := ScanContactsRoot(root)
if err != nil {
t.Fatal(err)
}
if len(doc.Entries) < 2 {
t.Fatalf("expected >=2 entries, got %d: %+v", len(doc.Entries), doc.Entries)
}
}
func TestScanProjectsRoot(t *testing.T) {
root := t.TempDir()
repo := filepath.Join(root, "produktor-demo")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
doc, err := ScanProjectsRoot(root, 3)
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range doc.Entries {
if e.Kind == "company" && e.Name == "produktor-demo" {
found = true
if e.Role != "work" {
t.Fatalf("role=%q", e.Role)
}
}
}
if !found {
t.Fatalf("missing company: %+v", doc.Entries)
}
}
func TestEntryID(t *testing.T) {
if got := EntryID("person", "A@B.COM", ""); got != "person:a@b.com" {
t.Fatal(got)
}
if got := EntryID("company", "", "Acme Corp"); got != "company:acme corp" {
t.Fatal(got)
}
}
+133
View File
@@ -0,0 +1,133 @@
package catalog
import (
"context"
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
)
// MatchAgainstOO sets status/oo_id by email then normalized name.
func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Document) error {
all, err := client.ListAllContacts(ctx)
if err != nil {
return err
}
byEmail := map[string]map[string]any{}
byPersonName := map[string]map[string]any{}
byCompanyName := map[string]map[string]any{}
for _, c := range all {
id := contactIDString(c)
isCo, _ := c["isCompany"].(bool)
for _, em := range contactEmails(c) {
byEmail[NormalizeEmail(em)] = c
}
if isCo {
key := NormalizeName(fmt.Sprint(c["displayName"]))
if key != "" {
byCompanyName[key] = c
}
continue
}
first := strings.TrimSpace(fmt.Sprint(c["firstName"]))
last := strings.TrimSpace(fmt.Sprint(c["lastName"]))
key := NormalizeName(first + " " + last)
if key == "" {
key = NormalizeName(fmt.Sprint(c["displayName"]))
}
if key != "" {
byPersonName[key] = c
}
_ = id
}
for i := range doc.Entries {
e := &doc.Entries[i]
// preserve approve
matched := false
conflict := false
var oo map[string]any
for _, em := range e.Emails {
if c, ok := byEmail[NormalizeEmail(em)]; ok {
if oo != nil && contactIDString(oo) != contactIDString(c) {
conflict = true
}
oo = c
matched = true
}
}
if !matched {
if e.Kind == "company" {
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
oo = c
matched = true
}
} else {
key := NormalizeName(strings.TrimSpace(e.First + " " + e.Last))
if key == "" {
key = NormalizeName(e.Name)
}
if c, ok := byPersonName[key]; ok {
oo = c
matched = true
}
}
}
if conflict {
e.Status = "conflict"
e.Notes = strings.TrimSpace(e.Notes + " email_matches_multiple_oo")
if oo != nil {
e.OOID = contactIDString(oo)
}
continue
}
if matched && oo != nil {
e.Status = "exists"
e.OOID = contactIDString(oo)
continue
}
e.Status = "new"
e.OOID = ""
}
return nil
}
func contactIDString(c map[string]any) string {
switch v := c["id"].(type) {
case float64:
return strconv.FormatInt(int64(v), 10)
case int:
return strconv.Itoa(v)
case string:
return v
default:
return fmt.Sprint(v)
}
}
func contactEmails(c map[string]any) []string {
var out []string
if em := strings.TrimSpace(fmt.Sprint(c["email"])); em != "" && em != "<nil>" {
out = append(out, em)
}
if em := strings.TrimSpace(fmt.Sprint(c["primaryEmail"])); em != "" && em != "<nil>" {
out = append(out, em)
}
for _, row := range onlyoffice.ContactInfoRows(c) {
t := strings.ToLower(fmt.Sprint(row["infoType"]))
if t != "email" {
continue
}
data := strings.TrimSpace(fmt.Sprint(row["data"]))
if data != "" {
out = append(out, data)
}
}
return uniqueEmails(out)
}
+26
View File
@@ -0,0 +1,26 @@
package catalog
import (
"testing"
)
func TestMatchAgainstOO_emailThenName(t *testing.T) {
// MatchAgainstOO needs a live client; unit-test the helpers used by matching.
c := map[string]any{
"id": float64(42),
"isCompany": false,
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"commonData": []any{
map[string]any{"infoType": "Email", "data": "ada.alt@example.com"},
},
}
if contactIDString(c) != "42" {
t.Fatal(contactIDString(c))
}
emails := contactEmails(c)
if len(emails) < 2 {
t.Fatalf("%v", emails)
}
}
+127
View File
@@ -0,0 +1,127 @@
package catalog
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ScanProjectsRoot finds git roots under root (max depth) and emits company rows.
func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
root = filepath.Clean(root)
if maxDepth <= 0 {
maxDepth = 4
}
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
var entries []Entry
err = walkGitRoots(root, root, 0, maxDepth, &entries)
if err != nil {
return nil, err
}
// Also top-level dirs as company stubs (even without git) — coverage 1B.
dents, _ := os.ReadDir(root)
for _, d := range dents {
if !d.IsDir() || strings.HasPrefix(d.Name(), ".") {
continue
}
name := d.Name()
skip := map[string]struct{}{
"node_modules": {}, "vendor": {}, ".cache": {},
}
if _, ok := skip[name]; ok {
continue
}
path := filepath.Join(root, name)
id := EntryID("company", "", name)
role, zone := classifyProjectName(name, "")
entries = append(entries, Entry{
ID: id,
Kind: "company",
Name: name,
Sources: []string{path},
Zone: zone,
Role: role,
Status: "new",
Notes: "top_level_dir",
})
}
return MergeDocs(&Document{Entries: entries}), nil
}
func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
if depth > maxDepth {
return nil
}
// is this a git root?
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (st.IsDir() || st.Mode().IsRegular()) {
name := filepath.Base(dir)
remote := gitRemoteOrigin(dir)
role, zone := classifyProjectName(name, remote)
*out = append(*out, Entry{
ID: EntryID("company", "", name),
Kind: "company",
Name: name,
Sources: []string{dir},
Remote: remote,
GitRoot: dir,
Zone: zone,
Role: role,
Status: "new",
})
return nil // do not descend into nested repos from a git root
}
dents, err := os.ReadDir(dir)
if err != nil {
return nil
}
for _, d := range dents {
if !d.IsDir() {
continue
}
name := d.Name()
if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" {
continue
}
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, out)
}
return nil
}
func gitRemoteOrigin(dir string) string {
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
b, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func classifyProjectName(name, remote string) (role, zone string) {
lower := strings.ToLower(name)
remoteL := strings.ToLower(remote)
switch {
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
return "experiment", "cold"
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
return "personal", "private"
case strings.Contains(remoteL, "git.produktor.io") || strings.Contains(remoteL, "github.com/eslider"):
return "work", "hot"
case strings.Contains(lower, "produktor") || strings.Contains(lower, "eslider") ||
strings.Contains(lower, "asesoria") || strings.Contains(lower, "dyvenia") ||
strings.Contains(lower, "onlyoffice"):
return "work", "warm"
default:
return "unknown", "warm"
}
}
+211
View File
@@ -0,0 +1,211 @@
// Package catalog builds a YAML inventory of CRM persons/companies from
// filesystem contacts (VCF, folders) and project trees, then matches/applies
// against OnlyOffice CRM.
package catalog
import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Entry is one catalog row (person or company).
type Entry struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"` // person | company
Name string `yaml:"name,omitempty"`
First string `yaml:"first,omitempty"`
Last string `yaml:"last,omitempty"`
Emails []string `yaml:"emails,omitempty"`
Phones []string `yaml:"phones,omitempty"`
Org string `yaml:"org,omitempty"`
Sources []string `yaml:"sources,omitempty"`
Zone string `yaml:"zone"`
Role string `yaml:"role"`
OOID string `yaml:"oo_id,omitempty"`
Approve bool `yaml:"approve"`
Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped
Notes string `yaml:"notes,omitempty"`
Remote string `yaml:"remote,omitempty"`
GitRoot string `yaml:"git_root,omitempty"`
}
// Document is the on-disk catalog file.
type Document struct {
GeneratedAt string `yaml:"generated_at"`
Entries []Entry `yaml:"entries"`
}
// LoadYAML reads a catalog document from path.
func LoadYAML(path string) (*Document, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc Document
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
return &doc, nil
}
// SaveYAML writes the catalog document.
func SaveYAML(path string, doc *Document) error {
doc.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
b, err := yaml.Marshal(doc)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// NormalizeEmail lowercases and trims.
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// NormalizeName collapses whitespace and lowercases for matching.
func NormalizeName(s string) string {
fields := strings.Fields(strings.ToLower(strings.TrimSpace(s)))
return strings.Join(fields, " ")
}
// SplitDisplayName splits "First Last …" into first/last (last = remainder).
func SplitDisplayName(name string) (first, last string) {
parts := strings.Fields(strings.TrimSpace(name))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
// EntryID builds a stable-ish id from kind + email or name.
func EntryID(kind, email, name string) string {
if e := NormalizeEmail(email); e != "" {
return fmt.Sprintf("%s:%s", kind, e)
}
return fmt.Sprintf("%s:%s", kind, NormalizeName(name))
}
// MergeDocs unions entries by id; later sources append sources[] and fill empties.
func MergeDocs(docs ...*Document) *Document {
byID := map[string]*Entry{}
order := []string{}
for _, d := range docs {
if d == nil {
continue
}
for i := range d.Entries {
e := d.Entries[i]
id := e.ID
if id == "" {
primary := ""
if len(e.Emails) > 0 {
primary = e.Emails[0]
}
name := e.Name
if name == "" {
name = strings.TrimSpace(e.First + " " + e.Last)
}
id = EntryID(e.Kind, primary, name)
e.ID = id
}
if prev, ok := byID[id]; ok {
mergeEntry(prev, &e)
} else {
cp := e
byID[id] = &cp
order = append(order, id)
}
}
}
out := &Document{Entries: make([]Entry, 0, len(order))}
for _, id := range order {
out.Entries = append(out.Entries, *byID[id])
}
return out
}
func mergeEntry(dst, src *Entry) {
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
if dst.First == "" {
dst.First = src.First
}
if dst.Last == "" {
dst.Last = src.Last
}
if dst.Name == "" {
dst.Name = src.Name
}
if dst.Org == "" {
dst.Org = src.Org
}
if dst.Remote == "" {
dst.Remote = src.Remote
}
if dst.GitRoot == "" {
dst.GitRoot = src.GitRoot
}
if dst.Notes == "" {
dst.Notes = src.Notes
}
// Prefer more specific zone/role from project scans over default private.
if dst.Zone == "private" && src.Zone != "" && src.Zone != "private" {
dst.Zone = src.Zone
}
if dst.Role == "" || (dst.Role == "unknown" && src.Role != "" && src.Role != "unknown") {
dst.Role = src.Role
}
// Preserve approve/oo_id/status from whichever already set.
if !dst.Approve && src.Approve {
dst.Approve = true
}
if dst.OOID == "" {
dst.OOID = src.OOID
}
if dst.Status == "" {
dst.Status = src.Status
}
}
func uniqueEmails(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, e := range in {
e = NormalizeEmail(e)
if e == "" {
continue
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
out = append(out, e)
}
return out
}
func uniqueStrings(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+218
View File
@@ -0,0 +1,218 @@
package catalog
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/emersion/go-vcard"
)
// ParseVCFFile extracts person candidates from a vCard file (may contain many cards).
func ParseVCFFile(path string) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
dec := vcard.NewDecoder(f)
var out []Entry
for {
card, err := dec.Decode()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return out, fmt.Errorf("%s: %w", path, err)
}
e := entryFromCard(card, path)
if e.Name == "" && e.First == "" && len(e.Emails) == 0 {
continue
}
out = append(out, e)
}
return out, nil
}
func entryFromCard(card vcard.Card, source string) Entry {
fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName))
n := card.Name()
first, last := "", ""
if n != nil {
first = strings.TrimSpace(n.GivenName)
last = strings.TrimSpace(n.FamilyName)
}
if fn == "" {
fn = strings.TrimSpace(first + " " + last)
}
if first == "" && last == "" && fn != "" {
first, last = SplitDisplayName(fn)
}
var emails, phones []string
for _, v := range card.Values(vcard.FieldEmail) {
if e := NormalizeEmail(v); e != "" {
emails = append(emails, e)
}
}
for _, v := range card.Values(vcard.FieldTelephone) {
p := strings.TrimSpace(v)
if p != "" {
phones = append(phones, p)
}
}
org := strings.TrimSpace(card.PreferredValue(vcard.FieldOrganization))
primary := ""
if len(emails) > 0 {
primary = emails[0]
}
return Entry{
ID: EntryID("person", primary, fn),
Kind: "person",
Name: fn,
First: first,
Last: last,
Emails: uniqueEmails(emails),
Phones: uniqueStrings(phones),
Org: org,
Sources: []string{source},
Zone: "private",
Role: "unknown",
Approve: false,
Status: "new",
}
}
// ScanContactsRoot walks a contacts directory: *.vcf anywhere, person-named
// subdirs, and *@*.txt email hint files.
func ScanContactsRoot(root string) (*Document, error) {
root = filepath.Clean(root)
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
var entries []Entry
seenVCF := map[string]struct{}{}
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable
}
if d.IsDir() {
base := d.Name()
if base == ".git" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
name := d.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".vcf") {
if _, ok := seenVCF[path]; ok {
return nil
}
seenVCF[path] = struct{}{}
parsed, perr := ParseVCFFile(path)
if perr != nil {
entries = append(entries, Entry{
ID: EntryID("person", "", filepath.Base(path)),
Kind: "person",
Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
Sources: []string{path},
Zone: "private",
Role: "unknown",
Notes: "vcf_parse_error: " + perr.Error(),
Status: "new",
})
return nil
}
entries = append(entries, parsed...)
return nil
}
// email hint files: name contains @ and ends with .txt
if strings.Contains(name, "@") && strings.HasSuffix(lower, ".txt") {
email := NormalizeEmail(strings.TrimSuffix(name, filepath.Ext(name)))
if !strings.Contains(email, "@") {
return nil
}
parent := filepath.Base(filepath.Dir(path))
first, last := SplitDisplayName(parent)
entries = append(entries, Entry{
ID: EntryID("person", email, parent),
Kind: "person",
Name: parent,
First: first,
Last: last,
Emails: []string{email},
Sources: []string{path},
Zone: "private",
Role: "unknown",
Status: "new",
})
}
return nil
})
if err != nil {
return nil, err
}
// Top-level person folders without VCF still become stub persons.
dents, _ := os.ReadDir(root)
for _, d := range dents {
if !d.IsDir() {
continue
}
name := d.Name()
if name == "Contacts VCF's" || strings.HasPrefix(name, ".") {
continue
}
// skip obvious non-person dumps
lower := strings.ToLower(name)
if strings.Contains(lower, "vcf") {
continue
}
first, last := SplitDisplayName(name)
id := EntryID("person", "", name)
entries = append(entries, Entry{
ID: id,
Kind: "person",
Name: name,
First: first,
Last: last,
Sources: []string{filepath.Join(root, name)},
Zone: "private",
Role: "unknown",
Status: "new",
Notes: "folder_stub",
})
}
doc := MergeDocs(&Document{Entries: entries})
return doc, nil
}
// ReadEmailsFromTxt reads loose emails from a text file (one per line or free text).
func ReadEmailsFromTxt(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.Contains(line, "@") && !strings.Contains(line, " ") {
out = append(out, NormalizeEmail(line))
}
}
return uniqueEmails(out), sc.Err()
}
+265
View File
@@ -0,0 +1,265 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/eslider/go-onlyoffice/catalog"
"github.com/spf13/cobra"
)
var catalogCmd = &cobra.Command{
Use: "catalog",
Short: "Inventory clients/contacts from disk; match and apply to OO CRM",
Long: `Build a YAML catalog of persons/companies from contacts folders and project
trees, match against live OnlyOffice CRM, then create/update only rows with
approve: true.
Typical flow:
oo catalog scan-contacts --root PATH -o /tmp/contacts.yaml
oo catalog scan-projects --root ~/projects -o /tmp/projects.yaml
oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -o docs/catalog/clients-contacts.yaml
oo catalog match -i docs/catalog/clients-contacts.yaml -o docs/catalog/clients-contacts.yaml
# edit approve: true on pilot rows
oo catalog apply --dry-run -i docs/catalog/clients-contacts.yaml
oo catalog apply --apply -i docs/catalog/clients-contacts.yaml -o docs/catalog/clients-contacts.yaml
`,
}
func init() {
rootCmd.AddCommand(catalogCmd)
catalogCmd.AddCommand(catalogScanContactsCmd())
catalogCmd.AddCommand(catalogScanProjectsCmd())
catalogCmd.AddCommand(catalogMergeCmd())
catalogCmd.AddCommand(catalogMatchCmd())
catalogCmd.AddCommand(catalogApplyCmd())
}
func catalogScanContactsCmd() *cobra.Command {
var outPath string
cmd := &cobra.Command{
Use: "scan-contacts",
Short: "Parse VCF + folder stubs + email txt under a contacts root",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := cmd.Flags().GetString("root")
if root == "" {
return fmt.Errorf("--root is required")
}
doc, err := catalog.ScanContactsRoot(root)
if err != nil {
return err
}
return writeCatalog(cmd, doc, outPath)
},
}
cmd.Flags().String("root", "", "contacts directory (local path)")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path (default: stdout summary)")
_ = cmd.MarkFlagRequired("root")
return cmd
}
func catalogScanProjectsCmd() *cobra.Command {
var outPath string
var maxDepth int
cmd := &cobra.Command{
Use: "scan-projects",
Short: "Git roots / remotes / top-level dirs → company rows",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := cmd.Flags().GetString("root")
if root == "" {
return fmt.Errorf("--root is required")
}
doc, err := catalog.ScanProjectsRoot(root, maxDepth)
if err != nil {
return err
}
return writeCatalog(cmd, doc, outPath)
},
}
cmd.Flags().String("root", "", "projects directory (local path)")
cmd.Flags().IntVar(&maxDepth, "max-depth", 4, "max directory depth for git roots")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
_ = cmd.MarkFlagRequired("root")
return cmd
}
func catalogMergeCmd() *cobra.Command {
var inputs []string
var outPath string
cmd := &cobra.Command{
Use: "merge",
Short: "Union + normalize catalog YAML files by entry id",
RunE: func(cmd *cobra.Command, args []string) error {
if len(inputs) == 0 {
return fmt.Errorf("at least one -i/--input required")
}
var docs []*catalog.Document
for _, p := range inputs {
d, err := catalog.LoadYAML(p)
if err != nil {
return err
}
docs = append(docs, d)
}
merged := catalog.MergeDocs(docs...)
if outPath == "" {
return fmt.Errorf("--out is required for merge")
}
if err := ensureParent(outPath); err != nil {
return err
}
if err := catalog.SaveYAML(outPath, merged); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "merged %d entries → %s\n", len(merged.Entries), outPath)
return nil
},
}
cmd.Flags().StringArrayVarP(&inputs, "input", "i", nil, "input catalog YAML (repeatable)")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output catalog YAML")
_ = cmd.MarkFlagRequired("out")
return cmd
}
func catalogMatchCmd() *cobra.Command {
var inPath, outPath string
cmd := &cobra.Command{
Use: "match",
Short: "Diff catalog vs live OO; set status new|exists|conflict and oo_id",
RunE: func(cmd *cobra.Command, args []string) error {
if inPath == "" {
return fmt.Errorf("--input is required")
}
doc, err := catalog.LoadYAML(inPath)
if err != nil {
return err
}
client, err := newOO(cmd)
if err != nil {
return err
}
if err := catalog.MatchAgainstOO(cmd.Context(), client, doc); err != nil {
return err
}
if outPath == "" {
outPath = inPath
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
counts := map[string]int{}
for _, e := range doc.Entries {
counts[e.Status]++
}
fmt.Fprintf(cmd.OutOrStdout(), "matched → %s new=%d exists=%d conflict=%d\n",
outPath, counts["new"], counts["exists"], counts["conflict"])
return nil
},
}
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output path (default: overwrite input)")
_ = cmd.MarkFlagRequired("input")
return cmd
}
func catalogApplyCmd() *cobra.Command {
var inPath, outPath string
var dryRun, doApply bool
cmd := &cobra.Command{
Use: "apply",
Short: "Create/update OO contacts for approve:true rows",
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun && !doApply {
return fmt.Errorf("pass --dry-run or --apply")
}
if dryRun && doApply {
return fmt.Errorf("use either --dry-run or --apply, not both")
}
if inPath == "" {
return fmt.Errorf("--input is required")
}
doc, err := catalog.LoadYAML(inPath)
if err != nil {
return err
}
client, err := newOO(cmd)
if err != nil {
return err
}
res, err := catalog.ApplyApproved(cmd.Context(), client, doc, dryRun)
if err != nil {
return err
}
if !dryRun {
if outPath == "" {
outPath = inPath
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
}
if outputFormat == "json" {
printJSON(res)
return nil
}
mode := "apply"
if dryRun {
mode = "dry-run"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: created=%d updated=%d skipped=%d errors=%d\n",
mode, res.Created, res.Updated, res.Skipped, len(res.Errors))
for _, e := range res.Errors {
fmt.Fprintf(cmd.ErrOrStderr(), " error: %s\n", e)
}
return nil
},
}
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write updated catalog after apply (default: overwrite input)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "count actions without writing to OO")
cmd.Flags().BoolVar(&doApply, "apply", false, "perform OO creates/updates")
_ = cmd.MarkFlagRequired("input")
return cmd
}
func writeCatalog(cmd *cobra.Command, doc *catalog.Document, outPath string) error {
if outPath != "" {
if err := ensureParent(outPath); err != nil {
return err
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote %d entries → %s\n", len(doc.Entries), outPath)
return nil
}
if outputFormat == "json" {
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), string(b))
return nil
}
persons, companies := 0, 0
for _, e := range doc.Entries {
if e.Kind == "company" {
companies++
} else {
persons++
}
}
fmt.Fprintf(cmd.OutOrStdout(), "entries=%d persons=%d companies=%d (pass -O PATH to write YAML)\n",
len(doc.Entries), persons, companies)
return nil
}
func ensureParent(path string) error {
dir := filepath.Dir(path)
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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,
+1
View File
@@ -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).
//
+47
View File
@@ -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 != "<nil>" {
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 == "<nil>" {
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
+2
View File
@@ -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 (
+5
View File
@@ -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=