ultisuite-backend/internal/nextcloud/contacts.go

233 lines
5.9 KiB
Go

package nextcloud
import (
"context"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
type AddressBook struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Path string `json:"path"`
}
type Contact struct {
UID string `json:"uid"`
FullName string `json:"full_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Org string `json:"org"`
RawVCard string `json:"raw_vcard,omitempty"`
}
func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) {
path := fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", userID)
body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseAddressBookList(resp.Body, path)
}
func (c *Client) ListContacts(ctx context.Context, userID, bookPath string) ([]Contact, error) {
body := `<?xml version="1.0" encoding="UTF-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
</card:addressbook-query>`
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseContactList(resp.Body)
}
func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error {
vcard := buildVCard(contact)
uid := contact.UID
if uid == "" {
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
}
contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid)
resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, map[string]string{
"Content-Type": "text/vcard; charset=utf-8",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 201 && resp.StatusCode != 204 {
return fmt.Errorf("create contact failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
return fmt.Errorf("delete contact failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query string) ([]Contact, error) {
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
<card:filter>
<card:prop-filter name="FN">
<card:text-match collation="i;unicode-casemap" match-type="contains">%s</card:text-match>
</card:prop-filter>
</card:filter>
</card:addressbook-query>`, query)
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseContactList(resp.Body)
}
func buildVCard(contact *Contact) string {
var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n")
b.WriteString("VERSION:3.0\r\n")
if contact.UID != "" {
b.WriteString(fmt.Sprintf("UID:%s\r\n", contact.UID))
}
b.WriteString(fmt.Sprintf("FN:%s\r\n", contact.FullName))
if contact.Email != "" {
b.WriteString(fmt.Sprintf("EMAIL;TYPE=INTERNET:%s\r\n", contact.Email))
}
if contact.Phone != "" {
b.WriteString(fmt.Sprintf("TEL;TYPE=CELL:%s\r\n", contact.Phone))
}
if contact.Org != "" {
b.WriteString(fmt.Sprintf("ORG:%s\r\n", contact.Org))
}
b.WriteString("END:VCARD\r\n")
return b.String()
}
func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error) {
var ms multistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
books := make([]AddressBook, 0)
for _, r := range ms.Responses {
if r.Href == basePath {
continue
}
name := r.Propstat.Prop.DisplayName
if name == "" {
continue
}
books = append(books, AddressBook{
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
DisplayName: name,
Path: r.Href,
})
}
return books, nil
}
func parseContactList(body io.Reader) ([]Contact, error) {
var ms cardMultistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
contacts := make([]Contact, 0, len(ms.Responses))
for _, r := range ms.Responses {
vcard := r.Propstat.Prop.AddressData
contact := parseVCard(vcard)
contact.RawVCard = vcard
contacts = append(contacts, contact)
}
return contacts, nil
}
func parseVCard(vcard string) Contact {
var c Contact
for _, line := range strings.Split(vcard, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "UID:"):
c.UID = strings.TrimPrefix(line, "UID:")
case strings.HasPrefix(line, "FN:"):
c.FullName = strings.TrimPrefix(line, "FN:")
case strings.HasPrefix(line, "EMAIL"):
if idx := strings.LastIndex(line, ":"); idx >= 0 {
c.Email = line[idx+1:]
}
case strings.HasPrefix(line, "TEL"):
if idx := strings.LastIndex(line, ":"); idx >= 0 {
c.Phone = line[idx+1:]
}
case strings.HasPrefix(line, "ORG:"):
c.Org = strings.TrimPrefix(line, "ORG:")
}
}
return c
}
type cardMultistatus struct {
XMLName xml.Name `xml:"multistatus"`
Responses []cardResponse `xml:"response"`
}
type cardResponse struct {
Href string `xml:"href"`
Propstat cardPropstat `xml:"propstat"`
}
type cardPropstat struct {
Prop cardProp `xml:"prop"`
}
type cardProp struct {
ETag string `xml:"getetag"`
AddressData string `xml:"address-data"`
}