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..0ce322d 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`, `scan-thunderbird`, `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,27 @@ 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 scan-thunderbird --root /path/to/thunderbird-profile -O /tmp/thunderbird.yaml +oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.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..bed3990 --- /dev/null +++ b/catalog/apply.go @@ -0,0 +1,199 @@ +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. +// Companies are applied before persons so Org links resolve in the same run. +func ApplyApproved(ctx context.Context, client *onlyoffice.Client, doc *Document, dryRun bool) (*ApplyResult, error) { + res := &ApplyResult{DryRun: dryRun} + order := make([]int, 0, len(doc.Entries)) + for i, e := range doc.Entries { + if e.Kind == "company" { + order = append(order, i) + } + } + for i, e := range doc.Entries { + if e.Kind != "company" { + order = append(order, i) + } + } + for _, i := range order { + 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/mbox.go b/catalog/mbox.go new file mode 100644 index 0000000..e4810aa --- /dev/null +++ b/catalog/mbox.go @@ -0,0 +1,238 @@ +package catalog + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// ScanOptions controls Thunderbird / mbox extraction depth. +type ScanOptions struct { + // MboxHeaders, when true, also walks mbox-like files under the root and + // extracts From/To/Cc/Reply-To addresses (headers only, no bodies). + MboxHeaders bool + // MboxMaxBytes skips individual mbox files larger than this (0 = 256 MiB default). + MboxMaxBytes int64 +} + +// ScanThunderbirdRoot finds Thunderbird profiles under root and emits person rows +// from address books (*.mab) and Gloda global-messages-db.sqlite. +func ScanThunderbirdRoot(root string) (*Document, error) { + return ScanThunderbirdRootOpts(root, ScanOptions{}) +} + +// ScanThunderbirdRootOpts is ScanThunderbirdRoot with optional mbox header pass. +func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*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) + } + if opts.MboxMaxBytes <= 0 { + opts.MboxMaxBytes = 256 << 20 + } + + var entries []Entry + seenDB := map[string]struct{}{} + seenMAB := map[string]struct{}{} + seenMbox := map[string]struct{}{} + + err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + name := d.Name() + if d.IsDir() { + switch name { + case "Cache", "cache2", "startupCache", "OfflineCache", "minidumps", + "crashes", "safebrowsing", "thumbnails", "chrome", "extensions", + "node_modules", ".git": + return filepath.SkipDir + } + return nil + } + lower := strings.ToLower(name) + switch { + case lower == "global-messages-db.sqlite": + if _, ok := seenDB[path]; ok { + return nil + } + seenDB[path] = struct{}{} + parsed, perr := parseGlodaContacts(path) + if perr != nil { + entries = append(entries, Entry{ + ID: EntryID("person", "", filepath.Base(path)), + Kind: "person", + Name: "gloda", + Sources: []string{path}, + Zone: "private", + Role: "unknown", + Notes: "gloda_parse_error: " + perr.Error(), + Status: "new", + }) + return nil + } + entries = append(entries, parsed...) + case strings.HasSuffix(lower, ".mab"): + if _, ok := seenMAB[path]; ok { + return nil + } + seenMAB[path] = struct{}{} + parsed, perr := parseMABEmails(path) + if perr != nil { + return nil + } + entries = append(entries, parsed...) + case opts.MboxHeaders && isLikelyMboxFile(name, path): + if _, ok := seenMbox[path]; ok { + return nil + } + seenMbox[path] = struct{}{} + info, ierr := d.Info() + if ierr != nil { + return nil + } + if info.Size() > opts.MboxMaxBytes { + return nil + } + parsed, perr := parseMboxHeaderEmails(path) + if perr != nil { + return nil + } + entries = append(entries, parsed...) + } + return nil + }) + if err != nil { + return nil, err + } + + return MergeDocs(&Document{Entries: entries}), nil +} + +func isLikelyMboxFile(name, path string) bool { + lower := strings.ToLower(name) + if strings.HasSuffix(lower, ".msf") || strings.HasSuffix(lower, ".sqlite") || + strings.HasSuffix(lower, ".mab") || strings.HasSuffix(lower, ".json") || + strings.HasSuffix(lower, ".dat") || strings.HasSuffix(lower, ".ini") || + strings.HasSuffix(lower, ".log") { + return false + } + if strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbx") { + return true + } + // Thunderbird: …/ImapMail//INBOX or …/Mail/Local Folders/Inbox + // or …/INBOX.sbd/ + sep := string(filepath.Separator) + norm := filepath.ToSlash(path) + if strings.Contains(norm, "/ImapMail/") || strings.Contains(norm, "/Mail/") { + if !strings.Contains(name, ".") { + return true + } + known := map[string]struct{}{ + "inbox": {}, "sent": {}, "drafts": {}, "trash": {}, "archives": {}, "junk": {}, + } + if _, ok := known[lower]; ok { + return true + } + } + if strings.Contains(filepath.Base(filepath.Dir(path)), ".sbd") && !strings.Contains(name, ".") { + return true + } + _ = sep + return false +} + +// parseMboxHeaderEmails extracts addresses from From/To/Cc/Reply-To headers only. +func parseMboxHeaderEmails(path string) ([]Entry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + emails := map[string]struct{}{} + br := bufio.NewReaderSize(f, 1<<20) + inHeaders := false + var headerBuf strings.Builder + + flushHeaders := func() { + if headerBuf.Len() == 0 { + return + } + block := headerBuf.String() + headerBuf.Reset() + for _, line := range strings.Split(block, "\n") { + lower := strings.ToLower(strings.TrimSpace(line)) + if strings.HasPrefix(lower, "from:") || strings.HasPrefix(lower, "to:") || + strings.HasPrefix(lower, "cc:") || strings.HasPrefix(lower, "reply-to:") || + strings.HasPrefix(lower, "sender:") { + for _, m := range emailRE.FindAllString(line, -1) { + em := NormalizeEmail(m) + if !noisyEmail(em) { + emails[em] = struct{}{} + } + } + } + } + } + + for { + line, err := br.ReadString('\n') + if len(line) > 0 { + // Classic mbox separator + if strings.HasPrefix(line, "From ") && (len(line) == 5 || line[5] != ':') { + flushHeaders() + inHeaders = true + continue + } + if inHeaders { + trimmed := strings.TrimRight(line, "\r\n") + if trimmed == "" { + flushHeaders() + inHeaders = false + continue + } + headerBuf.WriteString(trimmed) + headerBuf.WriteByte('\n') + // cap pathological headers + if headerBuf.Len() > 64<<10 { + flushHeaders() + inHeaders = false + } + } + } + if err == io.EOF { + flushHeaders() + break + } + if err != nil { + flushHeaders() + return nil, err + } + } + + var out []Entry + for em := range emails { + org, zone, role := classifyMailIdentity("", em) + out = append(out, Entry{ + ID: EntryID("person", em, ""), + Kind: "person", + Emails: []string{em}, + Org: org, + Sources: []string{path}, + Zone: zone, + Role: role, + Approve: false, + Status: "new", + Notes: "thunderbird_mbox_header", + }) + } + return out, nil +} diff --git a/catalog/mbox_test.go b/catalog/mbox_test.go new file mode 100644 index 0000000..67ad2f4 --- /dev/null +++ b/catalog/mbox_test.go @@ -0,0 +1,67 @@ +package catalog + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseMboxHeaderEmails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "INBOX") + body := `From - Mon Jul 1 00:00:00 2016 +From: Axel Schaefer +To: Andriy Oblivantsev +Cc: noreply@example.com, client@stadt-example.de +Subject: test + +Body line ignored +From - Mon Jul 2 00:00:00 2016 +From: Someone +To: list@wheregroup.com + +more body +` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + ents, err := parseMboxHeaderEmails(path) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range ents { + if len(e.Emails) > 0 { + got[e.Emails[0]] = true + } + } + if !got["axel.schaefer@wheregroup.com"] || !got["andriy.oblivantsev@wheregroup.com"] { + t.Fatalf("%v", got) + } + if got["noreply@example.com"] { + t.Fatal("noreply should be filtered") + } + if !got["client@stadt-example.de"] { + t.Fatal("missing client") + } +} + +func TestScanThunderbirdRootOptsMbox(t *testing.T) { + root := t.TempDir() + imap := filepath.Join(root, "ImapMail", "mail.example.com") + if err := os.MkdirAll(imap, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(imap, "INBOX"), []byte( + "From - x\nFrom: a@wheregroup.com\nTo: b@wheregroup.com\n\nbody\n", + ), 0o644); err != nil { + t.Fatal(err) + } + doc, err := ScanThunderbirdRootOpts(root, ScanOptions{MboxHeaders: true}) + if err != nil { + t.Fatal(err) + } + if len(doc.Entries) < 2 { + t.Fatalf("%+v", doc.Entries) + } +} 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/thunderbird.go b/catalog/thunderbird.go new file mode 100644 index 0000000..6b8265f --- /dev/null +++ b/catalog/thunderbird.go @@ -0,0 +1,191 @@ +package catalog + +import ( + "database/sql" + "os" + "regexp" + "strings" + + _ "modernc.org/sqlite" +) + +var emailRE = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) + +// noisyEmail reports addresses that should not become CRM catalog persons. +func noisyEmail(email string) bool { + e := NormalizeEmail(email) + if e == "" || !strings.Contains(e, "@") { + return true + } + local, domain, ok := strings.Cut(e, "@") + if !ok { + return true + } + noiseLocal := []string{ + "noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster", + "bounce", "notifications", "newsletter", "unsubscribe", + "root", "admin", "abuse", "webmaster", "hostmaster", + } + for _, p := range noiseLocal { + if local == p || strings.Contains(local, p) { + return true + } + } + // role / shared mailboxes (keep human dotted names) + roleExact := map[string]struct{}{ + "info": {}, "alle": {}, "all": {}, "office": {}, "office@": {}, + "wartung": {}, "starface": {}, "gitlab": {}, "pl": {}, "gf": {}, + "umsetzung": {}, "support": {}, "sales": {}, "billing": {}, + } + if _, ok := roleExact[local]; ok { + return true + } + if strings.HasSuffix(local, "-request") || strings.HasSuffix(local, "-owner") { + return true + } + noiseDomain := []string{ + "marketplace.amazon.", "reply.github.com", "users.noreply.github.com", + "groups.facebook.com", "googlegroups.com", "yahoogroups.", + "email.apple.com", "amazonses.com", "mailchimp.com", + "sendgrid.net", "mailgun.org", "postmarkapp.com", + "property.booking.com", "transvascular.com", + } + for _, p := range noiseDomain { + if strings.Contains(domain, p) { + return true + } + } + // random marketplace / tracking locals + if len(local) >= 16 && !strings.Contains(local, ".") && !strings.Contains(local, "-") { + if strings.Contains(domain, "amazon.") { + return true + } + } + return false +} + +func parseMABEmails(path string) ([]Entry, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + found := emailRE.FindAllString(string(b), -1) + var out []Entry + for _, raw := range found { + em := NormalizeEmail(raw) + if noisyEmail(em) { + continue + } + org, zone, role := classifyMailIdentity("", em) + out = append(out, Entry{ + ID: EntryID("person", em, ""), + Kind: "person", + Emails: []string{em}, + Org: org, + Sources: []string{path}, + Zone: zone, + Role: role, + Approve: false, + Status: "new", + Notes: "thunderbird_mab", + }) + } + return out, nil +} + +func parseGlodaContacts(dbPath string) ([]Entry, error) { + // read-only URI; immutable=1 helps when WAL/shm are missing + dsn := "file:" + dbPath + "?mode=ro&_pragma=query_only(1)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + defer db.Close() + + rows, err := db.Query(` + SELECT COALESCE(c.name, ''), i.value + FROM identities i + LEFT JOIN contacts c ON c.id = i.contactID + WHERE lower(i.kind) = 'email' AND i.value IS NOT NULL AND i.value != '' + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Entry + for rows.Next() { + var name, value string + if err := rows.Scan(&name, &value); err != nil { + return out, err + } + em := NormalizeEmail(value) + // Gloda sometimes stores "Name " in value + if m := emailRE.FindString(em); m != "" { + em = NormalizeEmail(m) + } + if noisyEmail(em) { + continue + } + display := strings.TrimSpace(name) + // strip wrapping quotes / email leftovers + display = strings.Trim(display, `"'`) + if idx := strings.Index(display, "<"); idx > 0 { + display = strings.TrimSpace(display[:idx]) + } + first, last := "", "" + if display != "" { + first, last = SplitDisplayName(display) + } + org, zone, role := classifyMailIdentity(display, em) + out = append(out, Entry{ + ID: EntryID("person", em, display), + Kind: "person", + Name: display, + First: first, + Last: last, + Emails: []string{em}, + Org: org, + Sources: []string{dbPath}, + Zone: zone, + Role: role, + Approve: false, + Status: "new", + Notes: "thunderbird_gloda", + }) + } + return out, rows.Err() +} + +func classifyMailIdentity(name, email string) (org, zone, role string) { + em := NormalizeEmail(email) + _, domain, _ := strings.Cut(em, "@") + switch { + case domain == "wheregroup.com" || strings.Contains(strings.ToLower(name), "wheregroup"): + return "WhereGroup", "warm", "work" + case domain == "produktor.io" || domain == "eslider.de" || strings.HasSuffix(domain, ".produktor.io"): + return "produktor.io", "hot", "work" + case domain == "dyvenia.com": + return "Dyvenia", "warm", "work" + case domain == "immowelt.de" || domain == "immowelt.com": + return "Immowelt", "warm", "work" + case strings.HasSuffix(domain, ".de") && looksPublicSector(domain): + return domain, "warm", "work" + default: + return "", "private", "unknown" + } +} + +func looksPublicSector(domain string) bool { + d := strings.ToLower(domain) + hints := []string{ + "stadt-", "stadt.", "kreis-", "gemeinde", "landkreis", + "bund.", "bundes", "lvermgeo", "rvr.", "eba.", + } + for _, h := range hints { + if strings.Contains(d, h) { + return true + } + } + return false +} diff --git a/catalog/thunderbird_test.go b/catalog/thunderbird_test.go new file mode 100644 index 0000000..1b366f7 --- /dev/null +++ b/catalog/thunderbird_test.go @@ -0,0 +1,97 @@ +package catalog + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestNoisyEmail(t *testing.T) { + if !noisyEmail("noreply@example.com") { + t.Fatal("expected noisy") + } + if !noisyEmail("x@marketplace.amazon.de") { + t.Fatal("amazon marketplace") + } + if noisyEmail("andriy.oblivantsev@wheregroup.com") { + t.Fatal("should keep wheregroup") + } +} + +func TestParseMABEmails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "abook.mab") + body := `// mork junk + PrimaryEmail=andriy.oblivantsev@wheregroup.com + noreply@github.com + axel.schaefer@wheregroup.com +` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + ents, err := parseMABEmails(path) + if err != nil { + t.Fatal(err) + } + if len(ents) != 2 { + t.Fatalf("got %d: %+v", len(ents), ents) + } + for _, e := range ents { + if e.Org != "WhereGroup" || e.Role != "work" { + t.Fatalf("%+v", e) + } + } +} + +func TestParseGlodaContacts(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "global-messages-db.sqlite") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE contacts (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE identities (id INTEGER PRIMARY KEY, contactID INTEGER, kind TEXT, value TEXT); + INSERT INTO contacts VALUES (1, 'Axel Schaefer'); + INSERT INTO identities VALUES (1, 1, 'email', 'axel.schaefer@wheregroup.com'); + INSERT INTO contacts VALUES (2, 'Noise Bot'); + INSERT INTO identities VALUES (2, 2, 'email', 'noreply@example.com'); + `) + if err != nil { + t.Fatal(err) + } + _ = db.Close() + + ents, err := parseGlodaContacts(dbPath) + if err != nil { + t.Fatal(err) + } + if len(ents) != 1 { + t.Fatalf("got %d %+v", len(ents), ents) + } + if ents[0].First != "Axel" || ents[0].Emails[0] != "axel.schaefer@wheregroup.com" { + t.Fatalf("%+v", ents[0]) + } +} + +func TestScanThunderbirdRoot(t *testing.T) { + root := t.TempDir() + prof := filepath.Join(root, "profile") + if err := os.MkdirAll(prof, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@wheregroup.com\n"), 0o644); err != nil { + t.Fatal(err) + } + doc, err := ScanThunderbirdRoot(root) + if err != nil { + t.Fatal(err) + } + if len(doc.Entries) < 1 { + t.Fatal(doc.Entries) + } +} 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..c43e92f --- /dev/null +++ b/cmd/oo/catalog.go @@ -0,0 +1,298 @@ +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 scan-thunderbird --root PATH -O /tmp/thunderbird.yaml + oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O docs/catalog/clients-contacts.yaml + oo catalog match -i 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 +`} + +func init() { + rootCmd.AddCommand(catalogCmd) + catalogCmd.AddCommand(catalogScanContactsCmd()) + catalogCmd.AddCommand(catalogScanProjectsCmd()) + catalogCmd.AddCommand(catalogScanThunderbirdCmd()) + 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 catalogScanThunderbirdCmd() *cobra.Command { + var outPath string + var mboxHeaders bool + cmd := &cobra.Command{ + Use: "scan-thunderbird", + Short: "Thunderbird profiles: abook/history.mab + Gloda SQLite contacts", + Long: `Walk a directory tree for Thunderbird profiles and extract person rows from: + - *.mab address books (email regex) + - global-messages-db.sqlite Gloda contacts/identities + - optional --mbox-headers: From/To/Cc/Reply-To from mbox folder files (no bodies) + +Noisy senders (noreply, Amazon marketplace, GitHub reply, …) are skipped. +Default zone is private; known work domains (e.g. wheregroup.com) get zone=warm role=work.`, + RunE: func(cmd *cobra.Command, args []string) error { + root, _ := cmd.Flags().GetString("root") + if root == "" { + return fmt.Errorf("--root is required") + } + doc, err := catalog.ScanThunderbirdRootOpts(root, catalog.ScanOptions{MboxHeaders: mboxHeaders}) + if err != nil { + return err + } + return writeCatalog(cmd, doc, outPath) + }, + } + cmd.Flags().String("root", "", "Thunderbird profile or parent directory (local path)") + cmd.Flags().BoolVar(&mboxHeaders, "mbox-headers", false, "also extract emails from mbox From/To/Cc headers") + 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..4300879 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 | scan-thunderbird | merge | match | apply // // Every list supports `--output/-o json|table` (table is the default). // diff --git a/crm.go b/crm.go index ea1cd76..54501c5 100644 --- a/crm.go +++ b/crm.go @@ -46,9 +46,12 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a } // FindCompany searches for a company contact with an exact (case-insensitive) -// displayName match. Returns nil when not found. +// displayName match after slogan/legal-suffix normalization. Returns nil when not found. func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) { needle := CompanyGroupingKey(name) + if needle == "" { + return nil, nil + } const page = 50 for start := 0; ; start += page { items, total, err := c.ListContacts(ctx, page, start, name) @@ -67,6 +70,19 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, break } } + // Search filter may miss longer legal names; fall back to a full company scan. + all, err := c.ListAllContacts(ctx) + if err != nil { + return nil, err + } + for _, co := range all { + if !isCompany(co) { + continue + } + if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle { + return co, nil + } + } return nil, nil } @@ -96,6 +112,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/crm_normalize.go b/crm_normalize.go index 08bef81..31e0bcc 100644 --- a/crm_normalize.go +++ b/crm_normalize.go @@ -10,6 +10,9 @@ var multiSpace = regexp.MustCompile(`\s+`) // sloganSeparators split a company name from a trailing tagline/slogan. var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"} +// companyLegalSuffix matches trailing legal-form tokens (GmbH, & Co. KG, …). +var companyLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(&\s*co\.?\s*kg\.?|co\.?\s*kg\.?|gmbh|ag|ltd\.?|llc\.?|inc\.?|plc\.?|s\.?l\.?|sarl|b\.?v\.?)\s*$`) + // StripSloganSuffix returns the part before an em/en dash tagline, e.g. // "Affirm — Fraud Engineering" → "Affirm". func StripSloganSuffix(s string) string { @@ -22,9 +25,23 @@ func StripSloganSuffix(s string) string { return s } -// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans). +// StripCompanyLegalSuffix removes trailing GmbH / & Co. KG / Ltd / … tokens. +func StripCompanyLegalSuffix(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimRight(s, ",; ") + for { + next := companyLegalSuffix.ReplaceAllString(s, "") + next = strings.TrimRight(strings.TrimSpace(next), ",; ") + if next == s || next == "" { + return s + } + s = next + } +} + +// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans and legal suffixes). func CompanyGroupingKey(s string) string { - return NormalizeCompanyName(StripSloganSuffix(s)) + return NormalizeCompanyName(StripCompanyLegalSuffix(StripSloganSuffix(s))) } // NormalizeCompanyName lowercases and collapses whitespace for grouping. diff --git a/crm_normalize_test.go b/crm_normalize_test.go index 8ce33e6..96b7fcd 100644 --- a/crm_normalize_test.go +++ b/crm_normalize_test.go @@ -65,6 +65,10 @@ func TestCompanyGroupingKey(t *testing.T) { if a != b { t.Fatalf("%q != %q", a, b) } + if CompanyGroupingKey("WhereGroup") != CompanyGroupingKey("WhereGroup GmbH & Co. KG,") { + t.Fatalf("legal suffix should not change grouping key: %q vs %q", + CompanyGroupingKey("WhereGroup"), CompanyGroupingKey("WhereGroup GmbH & Co. KG,")) + } } func TestOpportunityTitlesMatchSlogan(t *testing.T) { diff --git a/go.mod b/go.mod index 4c7b75e..6094400 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,15 @@ 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 + modernc.org/sqlite v1.54.0 ) require ( @@ -25,6 +28,8 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect @@ -34,13 +39,18 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark-emoji v1.0.3 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index e733ad8..6c0a467 100644 --- a/go.sum +++ b/go.sum @@ -33,14 +33,24 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -66,8 +76,12 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -87,15 +101,50 @@ github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +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= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=