From ebbd5d5373abfeca5f160312f201cbd683f55e42 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 20 Aug 2026 10:37:07 +0100 Subject: [PATCH 1/4] feat(files): add WebDAV-oriented Files operations Add value-oriented Files module operations for filesystem backends: ListDavFolder, ListDavSections (@root), CreateDavFolder, RenameDavFolder/ RenameDavFile, MoveDavItems, CopyDavItems, DeleteDavItems, UploadDavFile (streaming), DownloadDavFile. Dependency-free (net/http only). --- files_webdav.go | 421 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 files_webdav.go diff --git a/files_webdav.go b/files_webdav.go new file mode 100644 index 0000000..f3fcafd --- /dev/null +++ b/files_webdav.go @@ -0,0 +1,421 @@ +package onlyoffice + +// WebDAV-oriented Files operations. These expose the Documents module through +// value types and cover everything needed to back a filesystem mapping: +// listing (including the virtual @root sections), folder/file CRUD, move/copy, +// and streaming upload/download. They are intentionally small and dependency +// free (only net/http), so callers are not forced to import heavier parts of +// the library. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DavFolder is a folder row from the Files module. +type DavFolder struct { + ID string + Title string + ParentID string + RootType int // 1=Common, 3=Trash, 5=My, 6=Share, 8=Projects, ... + FilesCount int + FoldersCount int + Access int + Shared bool + Updated string +} + +// DavFile is a file row from the Files module. +type DavFile struct { + ID string + Title string + Size int64 + Updated string + ViewURL string +} + +// DavListing is the contents of one folder. +type DavListing struct { + Current DavFolder + Files []DavFile + Folders []DavFolder +} + +// ListDavFolder returns the contents of a folder by id, which may be a +// symbolic root such as "@my". For "@root" use ListDavSections. +func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id)) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + // @root returns an array with a single blob; a normal folder returns an + // object. Normalize both. + if len(resp) > 0 && resp[0] == '[' { + var arr []*DavListing + if err := json.Unmarshal(resp, &arr); err != nil { + return nil, err + } + if len(arr) == 0 { + return &DavListing{}, nil + } + return arr[0], nil + } + var l DavListing + if err := json.Unmarshal(resp, &l); err != nil { + return nil, err + } + return &l, nil +} + +// ListDavSections returns the virtual top-level sections shown by @root +// ("In projects", "My documents", "Shared with me", "Common", "Favorites", +// "Recent", "Trash"). Each is the `current` folder of one @root element. +func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/@root") + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var arr []struct { + Current DavFolder `json:"current"` + } + if err := json.Unmarshal(resp, &arr); err != nil { + // Tolerate a non-array (single listing) response. + var single DavListing + if err2 := json.Unmarshal(resp, &single); err2 != nil { + return nil, err + } + return []DavFolder{single.Current}, nil + } + sections := make([]DavFolder, 0, len(arr)) + for i := range arr { + sections = append(sections, arr[i].Current) + } + return sections, nil +} + +// CreateDavFolder creates a folder titled title inside parentID. +func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) { + raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID), + map[string]string{"title": title}) + if err != nil { + return nil, err + } + var env struct { + Response *DavFolder `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty create-folder response") + } + return env.Response, nil +} + +// RenameDavFolder renames a folder. +func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// RenameDavFile renames a file (title includes the extension). +func (c *Client) RenameDavFile(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// MoveDavItems moves the given folders and/or files into destFolderID. +func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "resolveType": "Skip", + "holdResult": true, + }) + return err +} + +// CopyDavItems copies the given folders and/or files into destFolderID. +func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "conflictResolveType": "Skip", + "deleteAfter": true, + }) + return err +} + +// DeleteDavItems deletes the given folders and/or files. +func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error { + body := map[string]any{"DeleteAfter": true, "Immediately": false} + for _, id := range folderIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), body); err != nil { + return err + } + } + for _, id := range fileIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), body); err != nil { + return err + } + } + return nil +} + +// UploadDavFile uploads src (fileName) into folderID, streaming from src. +func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) { + raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src) + if err != nil { + return nil, err + } + var env struct { + Response *DavFile `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty upload response") + } + return env.Response, nil +} + +// DownloadDavFile streams the file identified by id to w, returning bytes copied. +func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) { + file, err := c.GetFile(ctx, id) + if err != nil { + return 0, err + } + if file.ViewURL == nil || *file.ViewURL == "" { + return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id) + } + u := c.resolveAPIURL(*file.ViewURL) + auth, err := c.authHeader() + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", auth) + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode) + } + return io.Copy(w, resp.Body) +} + +// --- internal helpers ------------------------------------------------------- + +// deleteJSON performs an authenticated DELETE with an optional JSON body. +func (c *Client) deleteJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(buf) + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// uploadReader uploads a stream to path under the given form field name. +func (c *Client) uploadReader(ctx context.Context, path, fieldName, fileName string, src io.Reader) (json.RawMessage, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile(fieldName, fileName) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, src); err != nil { + return nil, err + } + if err := mw.Close(); err != nil { + return nil, err + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +func nums(ids []string) []json.Number { + out := make([]json.Number, 0, len(ids)) + for _, id := range ids { + if _, err := strconv.Atoi(id); err == nil { + out = append(out, json.Number(id)) + } + } + return out +} + +func num(id string) any { + if _, err := strconv.Atoi(id); err == nil { + return json.Number(id) + } + return id +} + +// UnmarshalJSON decodes a folder from the portal envelope, including fields +// that the base FolderEntry omits (parentId, rootFolderType, access, ...). +func (f *DavFolder) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + ParentID *json.Number `json:"parentId"` + RootType *int `json:"rootFolderType"` + FilesCount *int `json:"filesCount"` + FoldersCount *int `json:"foldersCount"` + Access *int `json:"access"` + Shared *bool `json:"shared"` + Updated *string `json:"updated"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.ParentID != nil { + f.ParentID = raw.ParentID.String() + } + if raw.RootType != nil { + f.RootType = *raw.RootType + } + if raw.FilesCount != nil { + f.FilesCount = *raw.FilesCount + } + if raw.FoldersCount != nil { + f.FoldersCount = *raw.FoldersCount + } + if raw.Access != nil { + f.Access = *raw.Access + } + if raw.Shared != nil { + f.Shared = *raw.Shared + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + return nil +} + +// UnmarshalJSON decodes a file row, capturing size and timestamps. +func (f *DavFile) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + PureSize *int64 `json:"pureContentLength"` + SizeStr *string `json:"contentLength"` + Updated *string `json:"updated"` + ViewURL *string `json:"viewUrl"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.PureSize != nil { + f.Size = *raw.PureSize + } else if raw.SizeStr != nil { + if n, err := strconv.ParseInt(strings.Fields(*raw.SizeStr)[0], 10, 64); err == nil { + f.Size = n + } + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + if raw.ViewURL != nil { + f.ViewURL = *raw.ViewURL + } + return nil +} + +// ModTime parses the folder's updated timestamp. +func (f *DavFolder) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +} + +// ModTime parses the file's updated timestamp. +func (f *DavFile) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +} From ecf34ba51aae77f1626a60795f7329f25d9344e5 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 20 Aug 2026 10:48:38 +0100 Subject: [PATCH 2/4] fix(files): rewrite viewUrl host to API base on download resolveAPIURL now rewrites absolute viewUrl hosts to the configured portal base so downloads stay on the internal network and keep the Authorization header (a cross-host HTTP->HTTPS redirect would otherwise strip it, failing large-file reads). --- files.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/files.go b/files.go index 794821c..c0d0cdd 100644 --- a/files.go +++ b/files.go @@ -319,8 +319,18 @@ func (c *Client) resolveAPIURL(ref string) string { if ref == "" { return ref } - if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { - return ref + // Rewrite any host to the configured API base so downloads stay on the + // internal network and keep the Authorization header (no cross-host + // redirect that would strip it). Scheme-relative URLs are handled too. + if strings.HasPrefix(ref, "//") { + ref = "http:" + ref + } + if u, err := url.Parse(ref); err == nil && u.IsAbs() { + if base, err2 := url.Parse(c.baseURL()); err2 == nil { + u.Scheme = base.Scheme + u.Host = base.Host + return u.String() + } } base := c.baseURL() if strings.HasPrefix(ref, "/") { From dda2bd3ca5cea6ebb89e1ac02b785b9838727bda Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 21 Aug 2026 16:43:00 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat(mail):=20oo=20mails=20send=20=E2=80=94?= =?UTF-8?q?=20SendMail=20+=20guard=20empty-by-id=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client.SendMail: PUT /api/2.0/mail/messages/send.json, omits empty cc/bcc - oo mails send subcommand with --id/--to/--subject/--body/--cc/--bcc - guard: sending by --id without --body errors (send.json does not copy subject/body from the draft; prevents empty emails) - unit test: empty cc/bcc omitted, correct path --- cmd/oo/mails.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ mails.go | 45 ++++++++++++++++++++++++++++++++- mails_test.go | 59 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 166 insertions(+), 4 deletions(-) diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index d6d5b4d..c49f362 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -26,6 +26,7 @@ func init() { mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) + mailsCmd.AddCommand(mailsSendCmd()) mailsCmd.AddCommand(mailsDeleteCmd()) } @@ -347,6 +348,71 @@ func writeMailAttachment(path string, body []byte) error { return os.WriteFile(path, body, 0o644) } +func mailsSendCmd() *cobra.Command { + var from, to, cc, bcc, subject, body, html string + var id int64 + cmd := &cobra.Command{ + Use: "send", + Short: "Send a mail message (OnlyOffice Mail)", + Long: `Send via PUT /api/2.0/mail/messages/send.json. + + oo mails send --id 7803 --body "…" # send referencing a draft id + oo mails send --to a@b.com --subject "…" --body "…" + oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com + +IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the +content must be in this request (--subject/--body). Cc/Bcc are omitted when empty +(the API 400s on empty strings). The API send does not append the UI signature — +put the chat line in --body if needed. +`, + RunE: func(cmd *cobra.Command, args []string) error { + if to == "" && id == 0 { + return fmt.Errorf("--to is required (or --id of an existing draft)") + } + htmlBody := body + if html != "" { + htmlBody = html + } + if htmlBody == "" && id != 0 { + // The send.json endpoint does NOT copy subject/body from the + // referenced draft — an empty body here sends an empty message. + // Warn instead of silently mailing an empty email. + return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)") + } + if htmlBody == "" && to == "" { + return fmt.Errorf("--body is required for a fresh message") + } + c, err := newOO(cmd) + if err != nil { + return err + } + raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{ + ID: id, + From: from, + To: to, + Cc: cc, + Bcc: bcc, + Subject: subject, + Body: htmlBody, + }) + if err != nil { + return err + } + fmt.Println(string(raw)) + return nil + }, + } + cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)") + cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)") + cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)") + cmd.Flags().StringVar(&cc, "cc", "", "cc") + cmd.Flags().StringVar(&bcc, "bcc", "", "bcc") + cmd.Flags().StringVar(&subject, "subject", "", "subject") + cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body") + cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)") + return cmd +} + func mailsDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/mails.go b/mails.go index 491f725..d15f821 100644 --- a/mails.go +++ b/mails.go @@ -8,8 +8,8 @@ import ( "encoding/json" "fmt" "io" - "net/mail" "net/http" + "net/mail" "net/url" "strconv" "strings" @@ -193,6 +193,49 @@ func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[ return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body) } +// SendMailParams describes a message to send via PUT /api/2.0/mail/messages/send. +// ID refers to an existing draft/message id; From falls back to the first enabled +// mailbox. Cc/Bcc are omitted when empty (the API 400s on empty strings). Chat +// line goes into Body (API send does not append the UI signature). +type SendMailParams struct { + ID int64 + From string + To string + Cc string + Bcc string + Subject string + Body string // HTML +} + +// SendMail sends an existing draft (or a fresh message) via the OnlyOffice Mail +// send endpoint. Returns the raw send response. +func (c *Client) SendMail(ctx context.Context, p SendMailParams) (json.RawMessage, error) { + if strings.TrimSpace(p.To) == "" { + return nil, fmt.Errorf("SendMail: to is required") + } + if strings.TrimSpace(p.From) == "" { + from, err := c.defaultMailFrom(ctx) + if err != nil { + return nil, err + } + p.From = from + } + body := map[string]any{ + "id": p.ID, + "from": p.From, + "to": p.To, + "subject": p.Subject, + "body": p.Body, + } + if strings.TrimSpace(p.Cc) != "" { + body["cc"] = p.Cc + } + if strings.TrimSpace(p.Bcc) != "" { + body["bcc"] = p.Bcc + } + return c.putJSON(ctx, "/api/2.0/mail/messages/send.json", body) +} + func (c *Client) defaultMailFrom(ctx context.Context) (string, error) { accounts, err := c.ListMailAccounts(ctx) if err != nil { diff --git a/mails_test.go b/mails_test.go index 86b2374..bf9a35c 100644 --- a/mails_test.go +++ b/mails_test.go @@ -2,6 +2,7 @@ package onlyoffice import ( "context" + "encoding/json" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -49,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) { func TestParseMailAddress(t *testing.T) { tests := []struct { - raw string - wantName string - wantAddress string + raw string + wantName string + wantAddress string }{ {`"LinkedIn Jobbenachrichtigungen" `, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, {`"Bitfinex" `, "Bitfinex", "no-reply@bitfinex.com"}, @@ -159,3 +160,55 @@ func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) { t.Fatalf("path = %q", gotPath) } } + +func TestSendMailOmitsEmptyCcBcc(t *testing.T) { + var gotBody map[string]any + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/2.0/authentication.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`)) + case "/api/2.0/mail/messages/send.json": + gotPath = r.URL.Path + dec := json.NewDecoder(r.Body) + _ = dec.Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"id":1}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + ctx := context.Background() + raw, err := c.SendMail(ctx, SendMailParams{ + ID: 99, + From: "me@x.com", + To: "a@b.com", + Subject: "hi", + Body: "

hello

", + }) + if err != nil { + t.Fatalf("SendMail: %v", err) + } + if gotPath != "/api/2.0/mail/messages/send.json" { + t.Fatalf("path = %q", gotPath) + } + if _, hasCC := gotBody["cc"]; hasCC { + t.Fatalf("empty cc should be omitted: %v", gotBody) + } + if _, hasBcc := gotBody["bcc"]; hasBcc { + t.Fatalf("empty bcc should be omitted: %v", gotBody) + } + if gotBody["to"] != "a@b.com" { + t.Fatalf("to = %v", gotBody["to"]) + } + if gotBody["id"] != float64(99) { + t.Fatalf("id = %v", gotBody["id"]) + } + if !strings.Contains(string(raw), `"id"`) { + t.Fatalf("raw = %s", raw) + } +} From 9c450a0352c25f12ac16d91062744ab026ffe660 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 21 Aug 2026 22:02:47 +0100 Subject: [PATCH 4/4] feat(crm): contact email & person-opportunity indexes, history entity whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildContactEmailIndex: one-pass email→contact id map over all persons (O(N) instead of per-address FindPersonByEmail O(N×M)) - BuildPersonOpportunityIndex: person→lowest-id opportunity for history notes (OO has no person-level history; verified live) - HistoryEntity{Opportunity,Case} whitelist constants - IsCompany/ContactID/NumericIDLess/SortIDs helpers with unit tests --- contact_indexes.go | 96 +++++++++++++++++++++++++++++++++++++++++ contact_indexes_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 contact_indexes.go create mode 100644 contact_indexes_test.go diff --git a/contact_indexes.go b/contact_indexes.go new file mode 100644 index 0000000..e056476 --- /dev/null +++ b/contact_indexes.go @@ -0,0 +1,96 @@ +package onlyoffice + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" +) + +// History entities that OnlyOffice CRM actually accepts for history notes. +// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json +// returns 400 "Value does not fall within the expected range." for entityType +// contact/person/people/client/member. Verified against a live instance (#74). +const ( + HistoryEntityOpportunity = "opportunity" + HistoryEntityCase = "case" +) + +// IsCompany reports whether a CRM contact row is a company (vs a person). +// The field arrives as JSON bool; be liberal about what we accept. +func IsCompany(person map[string]any) bool { + b, _ := person["isCompany"].(bool) + return b +} + +// ContactID returns the CRM id of a contact row as a plain string. +func ContactID(row map[string]any) string { + return fmt.Sprint(row["id"]) +} + +// BuildContactEmailIndex scans all persons once and maps lowercase email → +// contact id. Use this instead of calling FindPersonByEmail per address: +// the index is O(N) over the whole CRM, the per-address lookup is O(N×M). +func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) { + all, err := c.ListAllContacts(ctx) + if err != nil { + return nil, err + } + index := make(map[string]string, len(all)*2) + for _, person := range all { + if IsCompany(person) { + continue + } + id := ContactID(person) + for _, row := range ContactInfoRows(person) { + if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" { + continue + } + email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"]))) + if email != "" && email != "" { + index[email] = id + } + } + } + return index, nil +} + +// BuildPersonOpportunityIndex maps every opportunity member's contact id to a +// deterministic representative opportunity: the one with the lowest numeric id. +// OnlyOffice has no person-level history, so notes for a person go on their +// deal — this index answers "which deal" in one pass. +func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) { + opps, err := c.ListAllOpportunities(ctx) + if err != nil { + return nil, err + } + index := map[string]string{} + for _, opp := range opps { + oppID := ContactID(opp) + for _, member := range OpportunityMembers(opp) { + pid := ContactID(member) + if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) { + index[pid] = oppID + } + } + } + return index, nil +} + +// NumericIDLess compares two string ids numerically when possible, falling +// back to lexicographic order so results stay deterministic either way. +func NumericIDLess(a, b string) bool { + na, errA := strconv.Atoi(strings.TrimSpace(a)) + nb, errB := strconv.Atoi(strings.TrimSpace(b)) + if errA == nil && errB == nil && na != nb { + return na < nb + } + return a < b +} + +// SortIDs orders id strings deterministically (numeric first, then lexical). +func SortIDs(ids []string) { + sort.Strings(ids) + sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) }) +} diff --git a/contact_indexes_test.go b/contact_indexes_test.go new file mode 100644 index 0000000..50677a9 --- /dev/null +++ b/contact_indexes_test.go @@ -0,0 +1,65 @@ +package onlyoffice + +import "testing" + +func TestIsCompany(t *testing.T) { + if IsCompany(map[string]any{"isCompany": true}) != true { + t.Fatal("true row not detected") + } + if IsCompany(map[string]any{"isCompany": false}) { + t.Fatal("false row detected as company") + } + if IsCompany(map[string]any{}) { + t.Fatal("missing field detected as company") + } + if IsCompany(nil) { + t.Fatal("nil row detected as company") + } +} + +func TestNumericIDLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"9", "10", true}, + {"1747", "1748", true}, + {"abc", "abd", true}, + {"10", "9", false}, + {" 12 ", "13", true}, + {"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2" + } + for _, c := range cases { + if got := NumericIDLess(c.a, c.b); got != c.want { + t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want) + } + } +} + +func TestSortIDs(t *testing.T) { + ids := []string{"20", "3", "100", "1"} + SortIDs(ids) + want := "1 3 20 100" + got := "" + for i, id := range ids { + if i > 0 { + got += " " + } + got += id + } + if got != want { + t.Fatalf("SortIDs=%q want %q", got, want) + } +} + +func TestContactID(t *testing.T) { + if ContactID(map[string]any{"id": float64(42)}) != "42" { + t.Fatal("numeric id formatting broken") + } +} + +func TestHistoryEntityConstants(t *testing.T) { + if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" { + t.Fatal("history entity whitelist drifted from live-verified values") + } +}