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 91bc938..9d96072 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 +`~/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`) diff --git a/catalog/apply.go b/catalog/apply.go new file mode 100644 index 0000000..6589c13 --- /dev/null +++ b/catalog/apply.go @@ -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 +} diff --git a/catalog/catalog_test.go b/catalog/catalog_test.go new file mode 100644 index 0000000..47ed1e6 --- /dev/null +++ b/catalog/catalog_test.go @@ -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) + } +} diff --git a/catalog/match.go b/catalog/match.go new file mode 100644 index 0000000..c398c5e --- /dev/null +++ b/catalog/match.go @@ -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 != "" { + out = append(out, em) + } + if em := strings.TrimSpace(fmt.Sprint(c["primaryEmail"])); em != "" && em != "" { + 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) +} diff --git a/catalog/match_test.go b/catalog/match_test.go new file mode 100644 index 0000000..16f7e7c --- /dev/null +++ b/catalog/match_test.go @@ -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) + } +} diff --git a/catalog/scan_projects.go b/catalog/scan_projects.go new file mode 100644 index 0000000..e8ae348 --- /dev/null +++ b/catalog/scan_projects.go @@ -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" + } +} diff --git a/catalog/types.go b/catalog/types.go new file mode 100644 index 0000000..0a5a243 --- /dev/null +++ b/catalog/types.go @@ -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 +} diff --git a/catalog/vcf.go b/catalog/vcf.go new file mode 100644 index 0000000..45c6b5e --- /dev/null +++ b/catalog/vcf.go @@ -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() +} diff --git a/cmd/oo/catalog.go b/cmd/oo/catalog.go new file mode 100644 index 0000000..cdfe541 --- /dev/null +++ b/cmd/oo/catalog.go @@ -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) +} 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=