diff --git a/bin/onlyoffice/import-contact.go b/bin/onlyoffice/import-contact.go index 2e3217d..889e265 100644 --- a/bin/onlyoffice/import-contact.go +++ b/bin/onlyoffice/import-contact.go @@ -1,25 +1,24 @@ //usr/bin/env go run "$0" "$@"; exit +// // bin/onlyoffice/import-contact.go - read address-book files and reconcile // each contact into the OnlyOffice CRM (best effort; skip existing by email). // -// OO_URL=… OO_USER=… OO_PASSWORD=… ./bin/onlyoffice/import-contact.go --sources a.csv +// ONLYOFFICE_URL/USER/PASS ./bin/onlyoffice/import-contact.go --sources a.csv // ./bin/onlyoffice/import-contact.go --sources dir/ --dry-run +// +// Uses the go-onlyoffice library client (FindPersonByEmail is idempotent: +// matches commonData emails, so re-runs never duplicate). package main import ( "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" "os" "strings" - "time" "github.com/eSlider/2dph/pkg/cli" "github.com/eSlider/2dph/pkg/contact" + "github.com/eslider/go-onlyoffice" ) func main() { @@ -45,8 +44,8 @@ func run(args []string) int { var srcs []string for _, s := range sources { for _, part := range strings.Split(s, ",") { - if p := strings.TrimSpace(part); p != "" { - srcs = append(srcs, p) + if part = strings.TrimSpace(part); part != "" { + srcs = append(srcs, part) } } } @@ -60,98 +59,41 @@ func run(args []string) int { if dryRun { return 0 } - created, matched, failed, err := writeOO(context.Background(), cs) - if err != nil { - fmt.Fprintf(os.Stderr, "onlyoffice-import-contact: %v\n", err) - return 1 - } - fmt.Fprintf(os.Stderr, "oo: created=%d matched=%d failed=%d\n", created, matched, failed) - return 0 -} - -type ooConfig struct{ URL, User, Password string } -type ooClient struct { - cfg ooConfig - client *http.Client - token string -} - -func ooConfigFromEnv() (ooConfig, error) { - pick := func(a, b string) string { - if v := strings.TrimSpace(os.Getenv(a)); v != "" { - return v - } - return strings.TrimSpace(os.Getenv(b)) - } - cfg := ooConfig{ - URL: pick("ONLYOFFICE_URL", "OO_URL"), - User: pick("ONLYOFFICE_USER", "OO_USER"), - Password: pick("ONLYOFFICE_PASS", "OO_PASSWORD"), - } - if cfg.URL == "" || cfg.User == "" || cfg.Password == "" { - return cfg, fmt.Errorf("needs ONLYOFFICE_URL/USER/PASS (or OO_URL/USER/PASSWORD) env vars") - } - return cfg, nil -} - -func newOOClient(cfg ooConfig) (*ooClient, error) { - jar, _ := cookiejar.New(nil) - c := &ooClient{cfg: cfg, client: &http.Client{Jar: jar, Timeout: 60 * time.Second}} - body, _ := json.Marshal(map[string]any{"userName": cfg.User, "password": cfg.Password, "type": 0}) - resp, err := c.client.Post(strings.TrimRight(cfg.URL, "/")+"/api/2.0/authentication.json", - "application/json", strings.NewReader(string(body))) - if err != nil { - return nil, err - } - defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("oo authenticate status %d: %s", resp.StatusCode, truncate(string(data), 200)) - } - var out struct { - Response struct { - Token string `json:"token"` - } `json:"response"` - } - if err := json.Unmarshal(data, &out); err != nil || out.Response.Token == "" { - return nil, fmt.Errorf("oo authenticate: empty token") - } - c.token = out.Response.Token - return c, nil -} - -func writeOO(ctx context.Context, cs []contact.Contact) (created, matched, failed int, err error) { - cfg, err := ooConfigFromEnv() - if err != nil { - return 0, 0, 0, err - } - c, err := newOOClient(cfg) - if err != nil { - return 0, 0, 0, err - } - base := strings.TrimRight(cfg.URL, "/") + c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) + ctx := context.Background() + var created, matched, failed int for _, ct := range cs { email := first(ct.Emails) - if _, found, ferr := c.findPersonByEmail(ctx, base, email); ferr != nil { + existing, err := c.FindPersonByEmail(ctx, email) + if err != nil { failed++ - fmt.Fprintf(os.Stderr, "oo lookup %q: %v\n", ct.DisplayName(), ferr) + fmt.Fprintf(os.Stderr, "oo lookup %q: %v\n", ct.DisplayName(), err) continue - } else if found { + } + if existing != nil { matched++ fmt.Fprintf(os.Stderr, "oo matched %q (%s)\n", ct.DisplayName(), email) continue } - pid, cerr := c.createPerson(ctx, base, ct) - if cerr != nil { + person, err := c.CreatePerson(ctx, ct.Given, ct.Family, 0, ct.Title, ct.Org) + if err != nil { failed++ - fmt.Fprintf(os.Stderr, "oo create %q: %v\n", ct.DisplayName(), cerr) + fmt.Fprintf(os.Stderr, "oo create %q: %v\n", ct.DisplayName(), err) continue } + id := fmt.Sprint(person["id"]) + for i, e := range ct.Emails { + _, _ = c.AddContactInfo(ctx, id, "email", e, "Work", i == 0) + } + for _, ph := range ct.Phones { + _, _ = c.AddContactInfo(ctx, id, "phone", ph, "Work", false) + } created++ - fmt.Fprintf(os.Stderr, "oo created %q -> %s\n", ct.DisplayName(), pid) + fmt.Fprintf(os.Stderr, "oo created %q -> %s\n", ct.DisplayName(), id) } - return created, matched, failed, nil + fmt.Fprintf(os.Stderr, "oo: created=%d matched=%d failed=%d\n", created, matched, failed) + return 0 } func first(xs []string) string { @@ -160,118 +102,3 @@ func first(xs []string) string { } return xs[0] } - -func (c *ooClient) findPersonByEmail(ctx context.Context, base, email string) (string, bool, error) { - if email == "" { - return "", false, nil - } - u := base + "/api/2.0/crm/contact/filter.json?search=" + url.QueryEscape(email) - var out struct { - Response []map[string]any `json:"response"` - } - if err := c.get(ctx, u, &out); err != nil { - return "", false, err - } - for _, row := range out.Response { - if isCompany(row) { - continue - } - for _, k := range []string{"email", "primaryEmail"} { - if strings.EqualFold(strings.TrimSpace(fmt.Sprint(row[k])), email) { - return fmt.Sprint(row["id"]), true, nil - } - } - } - return "", false, nil -} - -func (c *ooClient) createPerson(ctx context.Context, base string, ct contact.Contact) (string, error) { - form := url.Values{} - form.Set("firstName", ct.Given) - form.Set("lastName", ct.Family) - if form.Get("firstName") == "" && form.Get("lastName") == "" { - form.Set("firstName", ct.DisplayName()) - } - if ct.Title != "" { - form.Set("jobTitle", ct.Title) - } - if ct.Org != "" { - form.Set("about", ct.Org) - } - var out struct { - Response struct { - ID string `json:"id"` - } `json:"response"` - } - if err := c.postForm(ctx, base+"/api/2.0/crm/contact/person.json", form, &out); err != nil { - return "", err - } - id := out.Response.ID - for _, e := range ct.Emails { - _ = c.addContactInfo(ctx, base, id, "email", e, "Work", e == first(ct.Emails)) - } - for _, p := range ct.Phones { - _ = c.addContactInfo(ctx, base, id, "phone", p, "Work", false) - } - return id, nil -} - -func (c *ooClient) addContactInfo(ctx context.Context, base, id, infoType, data, category string, primary bool) error { - form := url.Values{} - form.Set("infoType", infoType) - form.Set("data", data) - form.Set("category", category) - form.Set("isPrimary", fmt.Sprint(primary)) - var out any - return c.postForm(ctx, fmt.Sprintf("%s/api/2.0/crm/contact/%s/data.json", base, url.PathEscape(id)), form, &out) -} - -func (c *ooClient) get(ctx context.Context, u string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/json") - return c.do(req, out) -} - -func (c *ooClient) postForm(ctx context.Context, u string, form url.Values, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - return c.do(req, out) -} - -func (c *ooClient) do(req *http.Request, out any) error { - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return fmt.Errorf("oo %s %s: status %d: %s", req.Method, req.URL.Path, resp.StatusCode, truncate(string(data), 300)) - } - if out != nil { - return json.Unmarshal(data, out) - } - return nil -} - -func isCompany(m map[string]any) bool { - v, ok := m["isCompany"].(bool) - return ok && v -} - -func truncate(s string, n int) string { - s = strings.TrimSpace(s) - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/go.mod b/go.mod index 2d6cea4..46495af 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/chewxy/math32 v1.11.2 github.com/daulet/tokenizers v1.27.0 github.com/duckdb/duckdb-go/v2 v2.10505.0 + github.com/eslider/go-onlyoffice v0.13.0 github.com/go-git/go-git/v5 v5.19.2 github.com/integrii/flaggy v1.8.0 golang.org/x/sys v0.47.0 @@ -23,6 +24,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/fatih/color v1.19.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/inbucket/html2text v1.0.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect @@ -56,7 +58,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jhillyerd/enmime/v2 v2.4.1 github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -74,7 +76,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.56.0 gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 9774c05..b708dda 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTcc github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/eslider/go-onlyoffice v0.13.0 h1:VLRIz1I0KiiwSR6B+O5gpJDgXb67KXjJRUpoqw9Os2g= +github.com/eslider/go-onlyoffice v0.13.0/go.mod h1:TdEl0Cd74CWSk4B1K4+okR+feGNYoCsTSt4Cecz8jGI= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -73,6 +75,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= @@ -83,8 +87,11 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=