Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 29 additions & 202 deletions bin/onlyoffice/import-contact.go
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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)
}
}
}
Expand All @@ -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 {
Expand All @@ -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] + "..."
}
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
Loading