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 := ` ` 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 := ` ` 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(` %s `, 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"` }