reconcile(github): sync GitHub-only commits into Gitea main (#144) #6

Merged
eSlider merged 9 commits from reconcile/github-sync#144 into main 2026-08-23 19:55:48 +01:00
2 changed files with 161 additions and 0 deletions
Showing only changes of commit 9c450a0352 - Show all commits
+96
View File
@@ -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 != "<nil>" {
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]) })
}
+65
View File
@@ -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")
}
}