mirror of
https://github.com/hanzoskill/sogcli.git
synced 2026-10-30 21:03:29 +00:00
365 lines
9.3 KiB
Go
365 lines
9.3 KiB
Go
// Package carddav provides a CardDAV client for contact operations.
|
|
package carddav
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/emersion/go-vcard"
|
|
"github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/carddav"
|
|
)
|
|
|
|
// Client wraps a CardDAV client with convenience methods.
|
|
type Client struct {
|
|
client *carddav.Client
|
|
email string
|
|
url string
|
|
}
|
|
|
|
// Config holds CardDAV connection configuration.
|
|
type Config struct {
|
|
URL string // CardDAV server URL
|
|
Email string // Account email (for auth)
|
|
Password string // Account password
|
|
}
|
|
|
|
// Contact represents a contact/vCard.
|
|
type Contact struct {
|
|
UID string `json:"uid"`
|
|
FullName string `json:"full_name"`
|
|
FirstName string `json:"first_name,omitempty"`
|
|
LastName string `json:"last_name,omitempty"`
|
|
Emails []string `json:"emails,omitempty"`
|
|
Phones []string `json:"phones,omitempty"`
|
|
Org string `json:"org,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
Birthday string `json:"birthday,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
}
|
|
|
|
// AddressBook represents an address book.
|
|
type AddressBook struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// Connect establishes a connection to a CardDAV server.
|
|
func Connect(cfg Config) (*Client, error) {
|
|
httpClient := webdav.HTTPClientWithBasicAuth(http.DefaultClient, cfg.Email, cfg.Password)
|
|
|
|
client, err := carddav.NewClient(httpClient, cfg.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create CardDAV client: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
client: client,
|
|
email: cfg.Email,
|
|
url: cfg.URL,
|
|
}, nil
|
|
}
|
|
|
|
// Close closes the client connection.
|
|
func (c *Client) Close() error {
|
|
// CardDAV is stateless HTTP, nothing to close
|
|
return nil
|
|
}
|
|
|
|
// FindAddressBooks discovers available address books.
|
|
func (c *Client) FindAddressBooks(ctx context.Context) ([]AddressBook, error) {
|
|
// First find the address book home set
|
|
principal, err := c.client.FindCurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find principal: %w", err)
|
|
}
|
|
|
|
homeSet, err := c.client.FindAddressBookHomeSet(ctx, principal)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find address book home set: %w", err)
|
|
}
|
|
|
|
books, err := c.client.FindAddressBooks(ctx, homeSet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find address books: %w", err)
|
|
}
|
|
|
|
result := make([]AddressBook, 0, len(books))
|
|
for _, book := range books {
|
|
result = append(result, AddressBook{
|
|
Path: book.Path,
|
|
Name: book.Name,
|
|
Description: book.Description,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListContacts retrieves all contacts from an address book.
|
|
func (c *Client) ListContacts(ctx context.Context, bookPath string) ([]Contact, error) {
|
|
query := &carddav.AddressBookQuery{
|
|
DataRequest: carddav.AddressDataRequest{
|
|
Props: []string{
|
|
vcard.FieldUID,
|
|
vcard.FieldFormattedName,
|
|
vcard.FieldName,
|
|
vcard.FieldEmail,
|
|
vcard.FieldTelephone,
|
|
vcard.FieldOrganization,
|
|
vcard.FieldTitle,
|
|
vcard.FieldNote,
|
|
vcard.FieldBirthday,
|
|
vcard.FieldAddress,
|
|
vcard.FieldURL,
|
|
},
|
|
},
|
|
}
|
|
|
|
objects, err := c.client.QueryAddressBook(ctx, bookPath, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query address book: %w", err)
|
|
}
|
|
|
|
contacts := make([]Contact, 0, len(objects))
|
|
for _, obj := range objects {
|
|
contact := parseVCard(obj.Card)
|
|
contact.ETag = obj.ETag
|
|
contacts = append(contacts, contact)
|
|
}
|
|
|
|
return contacts, nil
|
|
}
|
|
|
|
// GetContact retrieves a single contact by UID.
|
|
func (c *Client) GetContact(ctx context.Context, bookPath, uid string) (*Contact, error) {
|
|
query := &carddav.AddressBookQuery{
|
|
DataRequest: carddav.AddressDataRequest{
|
|
AllProp: true,
|
|
},
|
|
PropFilters: []carddav.PropFilter{{
|
|
Name: vcard.FieldUID,
|
|
TextMatches: []carddav.TextMatch{{Text: uid}},
|
|
}},
|
|
}
|
|
|
|
objects, err := c.client.QueryAddressBook(ctx, bookPath, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query address book: %w", err)
|
|
}
|
|
|
|
if len(objects) == 0 {
|
|
return nil, fmt.Errorf("contact not found: %s", uid)
|
|
}
|
|
|
|
contact := parseVCard(objects[0].Card)
|
|
contact.ETag = objects[0].ETag
|
|
return &contact, nil
|
|
}
|
|
|
|
// SearchContacts searches for contacts matching a query.
|
|
func (c *Client) SearchContacts(ctx context.Context, bookPath, searchQuery string) ([]Contact, error) {
|
|
query := &carddav.AddressBookQuery{
|
|
DataRequest: carddav.AddressDataRequest{
|
|
AllProp: true,
|
|
},
|
|
PropFilters: []carddav.PropFilter{{
|
|
Name: vcard.FieldFormattedName,
|
|
TextMatches: []carddav.TextMatch{{Text: searchQuery, MatchType: carddav.MatchContains}},
|
|
}},
|
|
}
|
|
|
|
objects, err := c.client.QueryAddressBook(ctx, bookPath, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to search address book: %w", err)
|
|
}
|
|
|
|
contacts := make([]Contact, 0, len(objects))
|
|
for _, obj := range objects {
|
|
contact := parseVCard(obj.Card)
|
|
contact.ETag = obj.ETag
|
|
contacts = append(contacts, contact)
|
|
}
|
|
|
|
return contacts, nil
|
|
}
|
|
|
|
// CreateContact creates a new contact.
|
|
func (c *Client) CreateContact(ctx context.Context, bookPath string, contact *Contact) error {
|
|
card := createVCard(contact)
|
|
_, err := c.client.PutAddressObject(ctx, bookPath+"/"+contact.UID+".vcf", card)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create contact: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateContact updates an existing contact.
|
|
func (c *Client) UpdateContact(ctx context.Context, bookPath string, contact *Contact) error {
|
|
card := createVCard(contact)
|
|
_, err := c.client.PutAddressObject(ctx, bookPath+"/"+contact.UID+".vcf", card)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update contact: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteContact deletes a contact.
|
|
func (c *Client) DeleteContact(ctx context.Context, bookPath, uid string) error {
|
|
err := c.client.RemoveAll(ctx, bookPath+"/"+uid+".vcf")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete contact: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseVCard parses a vCard into a Contact.
|
|
func parseVCard(card vcard.Card) Contact {
|
|
contact := Contact{}
|
|
|
|
// UID
|
|
if field := card.Get(vcard.FieldUID); field != nil {
|
|
contact.UID = field.Value
|
|
}
|
|
|
|
// Full name (FN)
|
|
if field := card.Get(vcard.FieldFormattedName); field != nil {
|
|
contact.FullName = field.Value
|
|
}
|
|
|
|
// Structured name (N) - format: Family;Given;Additional;Prefix;Suffix
|
|
if field := card.Get(vcard.FieldName); field != nil {
|
|
parts := strings.Split(field.Value, ";")
|
|
if len(parts) >= 1 {
|
|
contact.LastName = parts[0]
|
|
}
|
|
if len(parts) >= 2 {
|
|
contact.FirstName = parts[1]
|
|
}
|
|
}
|
|
|
|
// Emails
|
|
for _, field := range card[vcard.FieldEmail] {
|
|
contact.Emails = append(contact.Emails, field.Value)
|
|
}
|
|
|
|
// Phones
|
|
for _, field := range card[vcard.FieldTelephone] {
|
|
contact.Phones = append(contact.Phones, field.Value)
|
|
}
|
|
|
|
// Organization
|
|
if field := card.Get(vcard.FieldOrganization); field != nil {
|
|
contact.Org = field.Value
|
|
}
|
|
|
|
// Title
|
|
if field := card.Get(vcard.FieldTitle); field != nil {
|
|
contact.Title = field.Value
|
|
}
|
|
|
|
// Note
|
|
if field := card.Get(vcard.FieldNote); field != nil {
|
|
contact.Note = field.Value
|
|
}
|
|
|
|
// Birthday
|
|
if field := card.Get(vcard.FieldBirthday); field != nil {
|
|
contact.Birthday = field.Value
|
|
}
|
|
|
|
// Addresses
|
|
for _, field := range card[vcard.FieldAddress] {
|
|
addr := field.Value
|
|
// Format address nicely if it has semicolons (vCard format)
|
|
addr = strings.ReplaceAll(addr, ";", ", ")
|
|
addr = strings.Trim(addr, ", ")
|
|
if addr != "" {
|
|
contact.Addresses = append(contact.Addresses, addr)
|
|
}
|
|
}
|
|
|
|
// URL
|
|
if field := card.Get(vcard.FieldURL); field != nil {
|
|
contact.URL = field.Value
|
|
}
|
|
|
|
return contact
|
|
}
|
|
|
|
// createVCard creates a vCard from a Contact.
|
|
func createVCard(contact *Contact) vcard.Card {
|
|
card := make(vcard.Card)
|
|
|
|
// Version (required)
|
|
card.SetValue(vcard.FieldVersion, "4.0")
|
|
|
|
// UID
|
|
card.SetValue(vcard.FieldUID, contact.UID)
|
|
|
|
// Full name (FN) - required
|
|
if contact.FullName != "" {
|
|
card.SetValue(vcard.FieldFormattedName, contact.FullName)
|
|
} else if contact.FirstName != "" || contact.LastName != "" {
|
|
card.SetValue(vcard.FieldFormattedName, strings.TrimSpace(contact.FirstName+" "+contact.LastName))
|
|
}
|
|
|
|
// Structured name (N)
|
|
if contact.FirstName != "" || contact.LastName != "" {
|
|
nameField := &vcard.Field{
|
|
Value: contact.LastName + ";" + contact.FirstName + ";;;",
|
|
}
|
|
card.Set(vcard.FieldName, nameField)
|
|
}
|
|
|
|
// Emails
|
|
for _, email := range contact.Emails {
|
|
field := &vcard.Field{Value: email}
|
|
card.Add(vcard.FieldEmail, field)
|
|
}
|
|
|
|
// Phones
|
|
for _, phone := range contact.Phones {
|
|
field := &vcard.Field{Value: phone}
|
|
card.Add(vcard.FieldTelephone, field)
|
|
}
|
|
|
|
// Organization
|
|
if contact.Org != "" {
|
|
card.SetValue(vcard.FieldOrganization, contact.Org)
|
|
}
|
|
|
|
// Title
|
|
if contact.Title != "" {
|
|
card.SetValue(vcard.FieldTitle, contact.Title)
|
|
}
|
|
|
|
// Note
|
|
if contact.Note != "" {
|
|
card.SetValue(vcard.FieldNote, contact.Note)
|
|
}
|
|
|
|
// Birthday
|
|
if contact.Birthday != "" {
|
|
card.SetValue(vcard.FieldBirthday, contact.Birthday)
|
|
}
|
|
|
|
// Addresses
|
|
for _, addr := range contact.Addresses {
|
|
field := &vcard.Field{Value: addr}
|
|
card.Add(vcard.FieldAddress, field)
|
|
}
|
|
|
|
// URL
|
|
if contact.URL != "" {
|
|
card.SetValue(vcard.FieldURL, contact.URL)
|
|
}
|
|
|
|
return card
|
|
}
|