diff --git a/plugins/carddav/.gitignore b/plugins/carddav/.gitignore new file mode 100644 index 0000000..d3d8ff3 --- /dev/null +++ b/plugins/carddav/.gitignore @@ -0,0 +1,18 @@ + +# Go template downloaded with gut +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +.gut + +# Dev files +*.log +devManifest.* +.init + +dist/ diff --git a/plugins/carddav/.goreleaser.yaml b/plugins/carddav/.goreleaser.yaml new file mode 100644 index 0000000..3d8802d --- /dev/null +++ b/plugins/carddav/.goreleaser.yaml @@ -0,0 +1,34 @@ + +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + binary: carddav + id: anyquery + ldflags: "-s -w" + flags: # To ensure reproducible builds + - -trimpath + + goarch: + - amd64 + - arm64 + +archives: + - format: binary + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/plugins/carddav/Makefile b/plugins/carddav/Makefile new file mode 100644 index 0000000..e82847e --- /dev/null +++ b/plugins/carddav/Makefile @@ -0,0 +1,22 @@ + +files := $(wildcard *.go) + +all: $(files) + go build -o carddav.out $(files) + +prod: $(files) + go build -o carddav.out -ldflags "-s -w" $(files) + +release: prod + goreleaser build -f .goreleaser.yaml --clean --snapshot + +test: + go test -v ./... + +integration-test: + ./test.sh + +clean: + rm -f carddav.out + +.PHONY: all clean test integration-test diff --git a/plugins/carddav/README.md b/plugins/carddav/README.md new file mode 100644 index 0000000..7a4316c --- /dev/null +++ b/plugins/carddav/README.md @@ -0,0 +1,140 @@ +# CardDAV plugin + +Query and manage CardDAV contacts with SQL. + +## Usage + +```sql +-- List all available address books +SELECT * FROM carddav_address_books; + +-- Get all contacts from a CardDAV address book +SELECT * FROM carddav_contacts WHERE address_book = 'contacts/'; + +-- Search for contacts by name +SELECT full_name, email, phone FROM carddav_contacts +WHERE address_book = 'contacts/' AND full_name LIKE '%John%'; + +-- Insert a new contact +INSERT INTO carddav_contacts (address_book, uid, full_name, email, phone) +VALUES ('contacts/', 'unique-id-123', 'John Doe', 'john@example.com', '+1234567890'); + +-- Update a contact +UPDATE carddav_contacts +SET email = 'newemail@example.com', organization = 'New Company' +WHERE address_book = 'contacts/' AND uid = 'unique-id-123'; +``` + +## Installation + +```bash +anyquery install carddav +``` + +Anyquery will ask you for your CardDAV server URL, username, and password during installation. Refer to the [guide](#popular-carddav-providers) below for more information on how to configure your CardDAV server. + +### Popular CardDAV Providers + +#### Nextcloud + +```txt +URL: https://your-nextcloud.com/remote.php/dav/addressbooks/users/yourusername/ +``` + +Create an app-specific password in Settings → Security → App passwords. + +#### Google Contacts + +Enable CardDAV API in Google Admin Console (for Workspace accounts). The Google Contacts API is not supported, but you can use Anyquery's integration for [Google Contacts](https://anyquery.dev/integrations/google_contacts). + +#### Apple iCloud + +```txt +URL: https://contacts.icloud.com/ +Username: The email used by your Apple account +Password: Your Apple ID password +``` + +Use an app-specific password from Apple ID settings. Refer to [Apple's documentation](https://support.apple.com/en-au/102654#:~:text=Sign%20in%20to%20your%20Apple%20Account%20on%20account.apple.com,the%20steps%20on%20your%20screen.) + +## Tables + +### `carddav_address_books` + +List available address books on the CardDAV server. + +#### Schema + +| Column index | Column name | Type | Description | +| ------------ | ----------------- | ------- | ----------------------------------- | +| 0 | path | TEXT | Address book path (use for queries) | +| 1 | name | TEXT | Display name of the address book | +| 2 | description | TEXT | Description of the address book | +| 3 | max_resource_size | INTEGER | Maximum resource size | + +### `carddav_contacts` + +Query and manage contacts from CardDAV address books. + +#### Schema + +| Column index | Column name | Type | Description | +| ------------ | ------------ | ---- | ----------------------------- | +| 0 | address_book | TEXT | Address book path (parameter) | +| 1 | uid | TEXT | Unique identifier | +| 2 | etag | TEXT | ETag for conflict detection | +| 3 | path | TEXT | CardDAV resource path | +| 4 | full_name | TEXT | Full display name | +| 5 | given_name | TEXT | First name | +| 6 | family_name | TEXT | Last name | +| 7 | middle_name | TEXT | Middle name | +| 8 | prefix | TEXT | Name prefix (Mr., Dr., etc.) | +| 9 | suffix | TEXT | Name suffix (Jr., Sr., etc.) | +| 10 | nickname | TEXT | Nickname | +| 11 | email | TEXT | Primary email address | +| 12 | home_email | TEXT | Home email address | +| 13 | work_email | TEXT | Work email address | +| 14 | other_email | TEXT | Other email address | +| 15 | emails | TEXT | All emails (JSON array) | +| 16 | phone | TEXT | Primary phone number | +| 17 | mobile_phone | TEXT | Mobile phone number | +| 18 | work_phone | TEXT | Work phone number | +| 19 | organization | TEXT | Organization/Company | +| 20 | title | TEXT | Job title | +| 21 | role | TEXT | Role/Position | +| 22 | birthday | TEXT | Birthday (YYYY-MM-DD) | +| 23 | anniversary | TEXT | Anniversary (YYYY-MM-DD) | +| 24 | note | TEXT | Notes | +| 25 | url | TEXT | Website URL | +| 26 | categories | TEXT | Categories (JSON array) | +| 27 | modified_at | TEXT | Last modified timestamp | + +## Development + +To develop and test the CardDAV plugin: + +```bash +cd plugins/carddav +make +make test # Run unit tests +make integration-test # Run integration tests with real CardDAV server +``` + +For manual testing, start anyquery in dev mode and load the plugin: + +```bash +anyquery --dev +``` + +```sql +SELECT load_dev_plugin('carddav', 'devManifest.json'); +``` + +Configure your CardDAV credentials in `devManifest.json` before running tests. The test script will verify plugin functionality by listing address books, querying contacts, and testing insert/update operations. + +## Limitations + +- Address book creation and deletion are not supported yet +- Some CardDAV servers may have different URL formats or authentication requirements +- Large contact lists may take time to query due to CardDAV protocol limitations +- The plugin does not cache data - each query hits the CardDAV server directly diff --git a/plugins/carddav/address_book.go b/plugins/carddav/address_book.go new file mode 100644 index 0000000..f70f4e7 --- /dev/null +++ b/plugins/carddav/address_book.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + + "github.com/emersion/go-webdav/carddav" + "github.com/julien040/anyquery/rpc" +) + +// Column indices for address_books table +const ( + addrBookColPath = iota + addrBookColName + addrBookColDescription + addrBookColMaxResourceSize + + // count + addrBookColCount +) + +var addressBookSchema = []rpc.DatabaseSchemaColumn{ + addrBookColPath: { + Name: "path", + Type: rpc.ColumnTypeString, + Description: "Address book path (use this for contacts queries)", + }, + addrBookColName: { + Name: "name", + Type: rpc.ColumnTypeString, + Description: "Display name of the address book", + }, + addrBookColDescription: { + Name: "description", + Type: rpc.ColumnTypeString, + Description: "Description of the address book", + }, + addrBookColMaxResourceSize: { + Name: "max_resource_size", + Type: rpc.ColumnTypeInt, + Description: "Maximum resource size", + }, +} + +func addressBooksCreator(args rpc.TableCreatorArgs) (rpc.Table, *rpc.DatabaseSchema, error) { + client, err := newCardDAVClient(args.UserConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to create CardDAV client: %w", err) + } + + return &addressBooksTable{client: client}, &rpc.DatabaseSchema{ + Columns: addressBookSchema, + }, nil +} + +type addressBooksTable struct { + client *carddav.Client +} + +type addressBooksCursor struct { + tbl *addressBooksTable +} + +func (t *addressBooksTable) CreateReader() rpc.ReaderInterface { + return &addressBooksCursor{tbl: t} +} + +func (t *addressBooksTable) Close() error { + return nil +} + +func (c *addressBooksCursor) Query(constraints rpc.QueryConstraint) ([][]any, bool, error) { + ctx := context.Background() + + var addressBooks []carddav.AddressBook + var err error + + // Method 1: Try standard CardDAV discovery + principal, err := c.tbl.client.FindAddressBookHomeSet(ctx, "") + if err == nil { + addressBooks, err = c.tbl.client.FindAddressBooks(ctx, principal) + if err == nil && len(addressBooks) > 0 { + // Success! Found address books via discovery + } else { + // Method 2: Try finding address books from root + addressBooks, err = c.tbl.client.FindAddressBooks(ctx, "/") + if err != nil || len(addressBooks) == 0 { + // Method 3: Try finding address books from the current user's principal + userPrincipal, userErr := c.tbl.client.FindCurrentUserPrincipal(ctx) + if userErr == nil { + homeSet, homeErr := c.tbl.client.FindAddressBookHomeSet(ctx, userPrincipal) + if homeErr == nil { + addressBooks, err = c.tbl.client.FindAddressBooks(ctx, homeSet) + } + } + } + } + } else { + // Method 2: Try finding address books from root + addressBooks, err = c.tbl.client.FindAddressBooks(ctx, "/") + if err != nil || len(addressBooks) == 0 { + // Method 3: Try finding address books from the current user's principal + userPrincipal, userErr := c.tbl.client.FindCurrentUserPrincipal(ctx) + if userErr == nil { + homeSet, homeErr := c.tbl.client.FindAddressBookHomeSet(ctx, userPrincipal) + if homeErr == nil { + addressBooks, err = c.tbl.client.FindAddressBooks(ctx, homeSet) + } + } + } + } + + // If all discovery methods failed, return an error + if len(addressBooks) == 0 { + return nil, true, fmt.Errorf("failed to discover address books using multiple methods. Check your CardDAV URL and credentials") + } + + rows := make([][]any, len(addressBooks)) + for i, book := range addressBooks { + row := make([]any, len(addressBookSchema)) + row[addrBookColPath] = book.Path + row[addrBookColName] = book.Name + row[addrBookColDescription] = book.Description + row[addrBookColMaxResourceSize] = book.MaxResourceSize + rows[i] = row + } + + return rows, true, nil +} diff --git a/plugins/carddav/address_book_test.go b/plugins/carddav/address_book_test.go new file mode 100644 index 0000000..b4b1f48 --- /dev/null +++ b/plugins/carddav/address_book_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "testing" + + "github.com/julien040/anyquery/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test schema creation +func TestAddressBookSchemaCreation(t *testing.T) { + server, _ := setupTestServer(t) + + config := map[string]any{ + "url": server.URL, + "username": "testuser", + "password": "testpass", + } + + args := rpc.TableCreatorArgs{UserConfig: config} + table, schema, err := addressBooksCreator(args) + require.NoError(t, err, "Failed to create address books table") + defer table.Close() + + // Verify schema + assert.Len(t, schema.Columns, addrBookColCount, "Schema column count mismatch") + + // Verify column names + expectedCols := []string{"path", "name", "description", "max_resource_size"} + for i, expectedName := range expectedCols { + require.Less(t, i, len(schema.Columns), "Column index out of bounds") + assert.Equal(t, expectedName, schema.Columns[i].Name, "Column %d name mismatch", i) + } +} + +// Test real cursor with HTTP mock discovery +func TestAddressBookCursor(t *testing.T) { + // Use the HTTP mock server that responds to PROPFIND requests + server := setupCardDAVMockServer(t) + + // Create a real table with the HTTP mock server + config := map[string]any{ + "url": server.URL, + "username": "testuser", + "password": "testpass", + } + + args := rpc.TableCreatorArgs{UserConfig: config} + table, _, err := addressBooksCreator(args) + require.NoError(t, err, "Failed to create address books table") + defer table.Close() + + // Create the actual cursor from the table + cursor := table.CreateReader() + require.NotNil(t, cursor, "Cursor should not be nil") + + // Test the REAL cursor implementation with HTTP mock that supports discovery + rows, eof, err := cursor.Query(rpc.QueryConstraint{}) + require.NoError(t, err, "Failed to query with real cursor using HTTP mock discovery") + + assert.True(t, eof, "Expected EOF to be true") + assert.Len(t, rows, 2, "Expected 2 address books") + + // Verify specific address book data + paths := []string{} + names := []string{} + for _, row := range rows { + paths = append(paths, row[addrBookColPath].(string)) + names = append(names, row[addrBookColName].(string)) + } + assert.Contains(t, paths, "/addressbooks/user/personal/", "Personal address book path not found") + assert.Contains(t, paths, "/addressbooks/user/work/", "Work address book path not found") + assert.Contains(t, names, "Personal", "Personal address book name not found") + assert.Contains(t, names, "Work", "Work address book name not found") +} diff --git a/plugins/carddav/carddav.go b/plugins/carddav/carddav.go new file mode 100644 index 0000000..8e5fedb --- /dev/null +++ b/plugins/carddav/carddav.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "net/http" + "time" + + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/carddav" +) + +func newCardDAVClient(config map[string]any) (*carddav.Client, error) { + url, ok := config["url"].(string) + if !ok || url == "" { + return nil, fmt.Errorf("url is required") + } + + username, ok := config["username"].(string) + if !ok || username == "" { + return nil, fmt.Errorf("username is required") + } + + password, ok := config["password"].(string) + if !ok || password == "" { + return nil, fmt.Errorf("password is required") + } + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + httpClientWithAuth := webdav.HTTPClientWithBasicAuth(httpClient, username, password) + + client, err := carddav.NewClient(httpClientWithAuth, url) + if err != nil { + return nil, fmt.Errorf("failed to create CardDAV client: %w", err) + } + + return client, nil +} diff --git a/plugins/carddav/carddav_test.go b/plugins/carddav/carddav_test.go new file mode 100644 index 0000000..0e7f6b6 --- /dev/null +++ b/plugins/carddav/carddav_test.go @@ -0,0 +1,369 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav/carddav" + "github.com/julien040/anyquery/rpc" +) + +// Mock CardDAV backend for testing +type mockCardDAVBackend struct { + addressBooks map[string]*carddav.AddressBook + contacts map[string]map[string]*carddav.AddressObject // addressbook -> uid -> contact +} + +func newMockBackend() *mockCardDAVBackend { + backend := &mockCardDAVBackend{ + addressBooks: make(map[string]*carddav.AddressBook), + contacts: make(map[string]map[string]*carddav.AddressObject), + } + + // Create default address books + backend.addressBooks["/addressbooks/user/personal/"] = &carddav.AddressBook{ + Path: "/addressbooks/user/personal/", + Name: "Personal", + Description: "Personal contacts", + } + backend.addressBooks["/addressbooks/user/work/"] = &carddav.AddressBook{ + Path: "/addressbooks/user/work/", + Name: "Work", + Description: "Work contacts", + } + + // Initialize contact maps + backend.contacts["/addressbooks/user/personal/"] = make(map[string]*carddav.AddressObject) + backend.contacts["/addressbooks/user/work/"] = make(map[string]*carddav.AddressObject) + + // Add some test contacts + backend.addTestContact("/addressbooks/user/personal/", createTestContact("test-1", "John Doe", "john@example.com", "+1234567890")) + backend.addTestContact("/addressbooks/user/personal/", createTestContact("test-2", "Jane Smith", "jane@example.com", "+0987654321")) + backend.addTestContact("/addressbooks/user/work/", createTestContact("test-3", "Bob Wilson", "bob@company.com", "+5555555555")) + + return backend +} + +func createTestContact(uid, fullName, email, phone string) *carddav.AddressObject { + card := make(vcard.Card) + card.SetValue(vcard.FieldVersion, "4.0") // Add required VERSION field + card.SetValue(vcard.FieldUID, uid) + card.SetValue(vcard.FieldFormattedName, fullName) + + // Parse name into components + parts := strings.Split(fullName, " ") + if len(parts) >= 2 { + name := &vcard.Name{ + GivenName: parts[0], + FamilyName: parts[len(parts)-1], + } + card.SetName(name) + } + + if email != "" { + card.AddValue(vcard.FieldEmail, email) + } + if phone != "" { + card.AddValue(vcard.FieldTelephone, phone) + } + + card.SetRevision(time.Now()) + + return &carddav.AddressObject{ + Path: fmt.Sprintf("/addressbooks/user/personal/%s.vcf", uid), + ModTime: time.Now(), + ETag: fmt.Sprintf(`"%s"`, uid), + Card: card, + } +} + +func (b *mockCardDAVBackend) addTestContact(addressBookPath string, contact *carddav.AddressObject) { + if b.contacts[addressBookPath] == nil { + b.contacts[addressBookPath] = make(map[string]*carddav.AddressObject) + } + uid := contact.Card.Value(vcard.FieldUID) + b.contacts[addressBookPath][uid] = contact +} + +// Implement carddav.Backend interface +func (b *mockCardDAVBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) { + return "/addressbooks/user/", nil +} + +func (b *mockCardDAVBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) { + var books []carddav.AddressBook + for _, book := range b.addressBooks { + books = append(books, *book) + } + return books, nil +} + +func (b *mockCardDAVBackend) GetAddressBook(ctx context.Context, path string) (*carddav.AddressBook, error) { + book, exists := b.addressBooks[path] + if !exists { + return nil, fmt.Errorf("address book not found: %s", path) + } + return book, nil +} + +func (b *mockCardDAVBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error { + b.addressBooks[addressBook.Path] = addressBook + b.contacts[addressBook.Path] = make(map[string]*carddav.AddressObject) + return nil +} + +func (b *mockCardDAVBackend) DeleteAddressBook(ctx context.Context, path string) error { + delete(b.addressBooks, path) + delete(b.contacts, path) + return nil +} + +func (b *mockCardDAVBackend) GetAddressObject(ctx context.Context, path string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { + // Extract address book and contact ID from path + parts := strings.Split(path, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid path: %s", path) + } + + contactID := strings.TrimSuffix(parts[len(parts)-1], ".vcf") + addressBookPath := "/" + strings.Join(parts[1:len(parts)-1], "/") + "/" + + contacts, exists := b.contacts[addressBookPath] + if !exists { + return nil, fmt.Errorf("address book not found: %s", addressBookPath) + } + + contact, exists := contacts[contactID] + if !exists { + return nil, fmt.Errorf("contact not found: %s", contactID) + } + + return contact, nil +} + +func (b *mockCardDAVBackend) ListAddressObjects(ctx context.Context, path string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { + contacts, exists := b.contacts[path] + if !exists { + return nil, fmt.Errorf("address book not found: %s", path) + } + + var objects []carddav.AddressObject + for _, contact := range contacts { + objects = append(objects, *contact) + } + return objects, nil +} + +func (b *mockCardDAVBackend) QueryAddressObjects(ctx context.Context, path string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) { + return b.ListAddressObjects(ctx, path, &query.DataRequest) +} + +func (b *mockCardDAVBackend) PutAddressObject(ctx context.Context, path string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { + // Extract address book path - handle double slash issue + cleanPath := strings.ReplaceAll(path, "//", "/") + parts := strings.Split(cleanPath, "/") + addressBookPath := "/" + strings.Join(parts[1:len(parts)-1], "/") + "/" + + contacts, exists := b.contacts[addressBookPath] + if !exists { + return nil, fmt.Errorf("address book not found: %s", addressBookPath) + } + + uid := card.Value(vcard.FieldUID) + if uid == "" { + uid = fmt.Sprintf("generated-%d", time.Now().Unix()) + card.SetValue(vcard.FieldUID, uid) + } + + log.Printf("PutAddressObject: %s %s", addressBookPath, uid) + + // Ensure vCard has VERSION field + if card.Value(vcard.FieldVersion) == "" { + card.SetValue(vcard.FieldVersion, "4.0") + } + + contact := &carddav.AddressObject{ + Path: cleanPath, + ModTime: time.Now(), + ETag: fmt.Sprintf(`"%s-%d"`, uid, time.Now().Unix()), + Card: card, + } + + contacts[uid] = contact + return contact, nil +} + +func (b *mockCardDAVBackend) DeleteAddressObject(ctx context.Context, path string) error { + // Extract address book and contact ID from path + parts := strings.Split(path, "/") + contactID := strings.TrimSuffix(parts[len(parts)-1], ".vcf") + addressBookPath := "/" + strings.Join(parts[1:len(parts)-1], "/") + "/" + + contacts, exists := b.contacts[addressBookPath] + if !exists { + return fmt.Errorf("address book not found: %s", addressBookPath) + } + + delete(contacts, contactID) + return nil +} + +// Implement webdav.UserPrincipalBackend interface +func (b *mockCardDAVBackend) CurrentUserPrincipal(ctx context.Context) (string, error) { + return "/principals/user/", nil +} + +// Test helper functions +func setupTestServer(t *testing.T) (*httptest.Server, *mockCardDAVBackend) { + backend := newMockBackend() + handler := &carddav.Handler{ + Backend: backend, + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server, backend +} + +func createTestPlugin(t *testing.T, serverURL string) (*contactsTable, *rpc.DatabaseSchema) { + config := map[string]any{ + "url": serverURL, + "username": "testuser", + "password": "testpass", + } + + args := rpc.TableCreatorArgs{ + UserConfig: config, + } + + table, schema, err := contactsCreator(args) + if err != nil { + t.Fatalf("Failed to create contacts table: %v", err) + } + + contactsTable, ok := table.(*contactsTable) + if !ok { + t.Fatalf("Expected *contactsTable, got %T", table) + } + + return contactsTable, schema +} + +// Create a mock HTTP server that properly responds to CardDAV PROPFIND requests +func setupCardDAVMockServer(t *testing.T) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle CardDAV discovery requests + if r.Method == "PROPFIND" { + handlePropfind(w, r) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + return server +} + +func handlePropfind(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Handle different discovery endpoints + switch { + case path == "/" || path == "": + // Root discovery - return current user principal + respondWithCurrentUserPrincipal(w) + case strings.HasPrefix(path, "/principals/"): + // Principal discovery - return address book home set + respondWithAddressBookHomeSet(w) + case strings.HasPrefix(path, "/addressbooks/"): + // Address book discovery - return available address books + respondWithAddressBooks(w) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func respondWithCurrentUserPrincipal(w http.ResponseWriter) { + response := ` + + + / + + + + /principals/user/ + + + HTTP/1.1 200 OK + + +` + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + w.Write([]byte(response)) +} + +func respondWithAddressBookHomeSet(w http.ResponseWriter) { + response := ` + + + /principals/user/ + + + + /addressbooks/user/ + + + HTTP/1.1 200 OK + + +` + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + w.Write([]byte(response)) +} + +func respondWithAddressBooks(w http.ResponseWriter) { + response := ` + + + /addressbooks/user/personal/ + + + + + + + Personal + Personal contacts + + HTTP/1.1 200 OK + + + + /addressbooks/user/work/ + + + + + + + Work + Work contacts + + HTTP/1.1 200 OK + + +` + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + w.Write([]byte(response)) +} diff --git a/plugins/carddav/contacts.go b/plugins/carddav/contacts.go new file mode 100644 index 0000000..99d45e4 --- /dev/null +++ b/plugins/carddav/contacts.go @@ -0,0 +1,641 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "slices" + "strings" + "time" + + "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav/carddav" + "github.com/julien040/anyquery/rpc" +) + +// Column indices for contacts table schema +const ( + // parameter columns + colAddressBook = iota + paramCount // total number of parameter columns + + // data columns + colUID = iota - 1 + colETag + colPath // Primary key + colFullName + colGivenName + colFamilyName + colMiddleName + colPrefix + colSuffix + colNickname + colEmail + colHomeEmail + colWorkEmail + colOtherEmail + colEmails + colPhone + colMobilePhone + colWorkPhone + colOrganization + colTitle + colRole + colBirthday + colAnniversary + colNote + colURL + colCategories + colModifiedAt + colRawVCard + + colCount // total number of columns (including parameter columns) +) + +var contactsSchema = []rpc.DatabaseSchemaColumn{ + // parameter columns + colAddressBook: { + Name: "address_book", + Type: rpc.ColumnTypeString, + IsParameter: true, + IsRequired: true, + Description: "The address book path to query", + }, + + // data columns + colUID: { + Name: "uid", + Type: rpc.ColumnTypeString, + Description: "Unique identifier for the contact", + }, + colETag: { + Name: "etag", + Type: rpc.ColumnTypeString, + Description: "ETag for conflict detection", + }, + colPath: { + Name: "path", + Type: rpc.ColumnTypeString, + Description: "CardDAV resource path", + }, + colFullName: { + Name: "full_name", + Type: rpc.ColumnTypeString, + Description: "Full display name", + }, + colGivenName: { + Name: "given_name", + Type: rpc.ColumnTypeString, + Description: "First name", + }, + colFamilyName: { + Name: "family_name", + Type: rpc.ColumnTypeString, + Description: "Last name", + }, + colMiddleName: { + Name: "middle_name", + Type: rpc.ColumnTypeString, + Description: "Middle name", + }, + colPrefix: { + Name: "prefix", + Type: rpc.ColumnTypeString, + Description: "Name prefix (Mr., Dr., etc.)", + }, + colSuffix: { + Name: "suffix", + Type: rpc.ColumnTypeString, + Description: "Name suffix (Jr., III, etc.)", + }, + colNickname: { + Name: "nickname", + Type: rpc.ColumnTypeString, + Description: "Nickname", + }, + colEmail: { + Name: "email", + Type: rpc.ColumnTypeString, + Description: "Primary email address", + }, + colHomeEmail: { + Name: "home_email", + Type: rpc.ColumnTypeString, + Description: "Home email address", + }, + colWorkEmail: { + Name: "work_email", + Type: rpc.ColumnTypeString, + Description: "Work email address", + }, + colOtherEmail: { + Name: "other_email", + Type: rpc.ColumnTypeString, + Description: "Other email address", + }, + colEmails: { + Name: "emails", + Type: rpc.ColumnTypeJSON, + Description: "Work email address", + }, + colPhone: { + Name: "phone", + Type: rpc.ColumnTypeString, + Description: "Primary phone number", + }, + colMobilePhone: { + Name: "mobile_phone", + Type: rpc.ColumnTypeString, + Description: "Mobile phone number", + }, + colWorkPhone: { + Name: "work_phone", + Type: rpc.ColumnTypeString, + Description: "Work phone number", + }, + colOrganization: { + Name: "organization", + Type: rpc.ColumnTypeString, + Description: "Company/organization name", + }, + colTitle: { + Name: "title", + Type: rpc.ColumnTypeString, + Description: "Job title", + }, + colRole: { + Name: "role", + Type: rpc.ColumnTypeString, + Description: "Job role", + }, + colBirthday: { + Name: "birthday", + Type: rpc.ColumnTypeString, + Description: "Birthday in RFC3339 format", + }, + colAnniversary: { + Name: "anniversary", + Type: rpc.ColumnTypeString, + Description: "Anniversary in RFC3339 format", + }, + colNote: { + Name: "note", + Type: rpc.ColumnTypeString, + Description: "Notes", + }, + colURL: { + Name: "url", + Type: rpc.ColumnTypeString, + Description: "Associated website URL", + }, + colCategories: { + Name: "categories", + Type: rpc.ColumnTypeJSON, + Description: "Categories/tags (comma-separated)", + }, + colModifiedAt: { + Name: "modified_at", + Type: rpc.ColumnTypeDateTime, + Description: "Last modification time (RFC3339)", + }, + colRawVCard: { + Name: "raw_vcard", + Type: rpc.ColumnTypeString, + Description: "Complete vCard data", + }, +} + +func contactsCreator(args rpc.TableCreatorArgs) (rpc.Table, *rpc.DatabaseSchema, error) { + client, err := newCardDAVClient(args.UserConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to create CardDAV client: %w", err) + } + + return &contactsTable{client: client}, &rpc.DatabaseSchema{ + PrimaryKey: colPath, // we use path as primary key as it directly maps to the CardDAV path + HandlesInsert: true, + HandlesUpdate: true, + HandlesDelete: true, + Columns: contactsSchema, + }, nil +} + +type contactsTable struct { + client *carddav.Client +} + +func (t *contactsTable) CreateReader() rpc.ReaderInterface { + return &contactsCursor{tbl: t} +} + +func (t *contactsTable) Close() error { + return nil +} + +type contactsCursor struct{ tbl *contactsTable } + +func (c *contactsCursor) Query(constraints rpc.QueryConstraint) ([][]any, bool, error) { + // Check if address_book constraint exists + constraint := constraints.GetColumnConstraint(colAddressBook) + if constraint == nil { + return nil, true, fmt.Errorf("address_book parameter is required") + } + + // Get the address book path - empty string is valid for root collection + addressBook := constraint.GetStringValue() + + ctx := context.Background() + + // Query all contacts from the specified address book + query := &carddav.AddressBookQuery{ + // Request all contact data + DataRequest: carddav.AddressDataRequest{ + AllProp: true, + }, + Limit: constraints.Limit, + } + + contacts, err := c.tbl.client.QueryAddressBook(ctx, addressBook, query) + if err != nil { + return nil, true, fmt.Errorf("failed to query address book '%s': %w", addressBook, err) + } + + // Convert contacts to rows + rows := make([][]any, len(contacts)) + for i, contact := range contacts { + row := parseVCardToRow(&contact) + + // skip parameter columns + rows[i] = row[paramCount:] + } + + return rows, true, nil +} + +func (t *contactsTable) Insert(rows [][]any) error { + ctx := context.Background() + + for _, rowCols := range rows { + // Extract address book from first column (parameter) + // Empty string is valid for root collection + addressBook, ok := rowCols[colAddressBook].(string) + if !ok || addressBook == "" { + return fmt.Errorf("address book is required for insert") + } + + card := make(vcard.Card) + + // Set required VERSION field + card.SetValue(vcard.FieldVersion, "4.0") + + // Set UID if provided, otherwise generate one + uid := rowCols[colUID].(string) + if uid == "" { + uid = fmt.Sprintf("contact-%d", time.Now().Unix()) + } + card.SetValue(vcard.FieldUID, uid) + + // Create vCard from row data + if err := updateVCardFromRow(card, rowCols); err != nil { + return fmt.Errorf("failed to create vCard: %w", err) + } + + // Generate a path for the new contact + cleanAddressBook := strings.TrimSuffix(addressBook, "/") + contactPath := fmt.Sprintf("%s/%s.vcf", cleanAddressBook, uid) + + // Save the contact to the CardDAV server + _, err := t.client.PutAddressObject(ctx, contactPath, card) + if err != nil { + return fmt.Errorf("failed to create contact at '%s': %w", contactPath, err) + } + } + + return nil +} + +func (t *contactsTable) Update(rows [][]any) error { + ctx := context.Background() + + log.Printf("Updating %+v rows", rows) + + for _, rowCols := range rows { + // first column is primary key that's being updated + contactPath := rowCols[0].(string) + rowCols = rowCols[1:] + + // GetAddressObject fails for some reason on nextcloud, so we directly open the file and parse it instead + r, err := t.client.Open(ctx, contactPath) + if err != nil { + return fmt.Errorf("failed to open existing contact '%s': %w", contactPath, err) + } + + card, err := vcard.NewDecoder(r).Decode() + if err != nil { + r.Close() + return fmt.Errorf("failed to decode existing contact '%s': %w", contactPath, err) + } + r.Close() + + // Update vCard from row data + if err := updateVCardFromRow(card, rowCols); err != nil { + return fmt.Errorf("failed to create vCard: %w", err) + } + + log.Printf("Updating contact '%s' with '%v'", contactPath, card) + + // TODO: Implement ETag checking for conflict detection + // For now, we'll just overwrite + + // Save the updated contact + _, err = t.client.PutAddressObject(ctx, contactPath, card) + if err != nil { + return fmt.Errorf("failed to update contact '%s': %w", contactPath, err) + } + } + + return nil +} + +func (t *contactsTable) Delete(primaryKeys []any) error { + ctx := context.Background() + + for _, primaryKey := range primaryKeys { + // first column is primary key that's being deleted + contactPath := primaryKey.(string) + log.Printf("Deleting contact '%s'", contactPath) + + err := t.client.RemoveAll(ctx, contactPath) + if err != nil { + return fmt.Errorf("failed to delete contact '%s': %w", contactPath, err) + } + } + + return nil +} + +func parseVCardToRow(addressObj *carddav.AddressObject) []any { + card := addressObj.Card + + // Parameter columns are not included in row data + // Use rowCount for the row size and row* constants for indexing + row := make([]any, len(contactsSchema)) + + // Use clean row indices (no parameter columns) + row[colUID] = card.Value(vcard.FieldUID) + row[colETag] = addressObj.ETag + row[colPath] = addressObj.Path + row[colFullName] = card.Value(vcard.FieldFormattedName) + + // Parse structured name (N field) + name := card.Name() + if name != nil { + row[colGivenName] = name.GivenName + row[colFamilyName] = name.FamilyName + row[colMiddleName] = name.AdditionalName + row[colPrefix] = name.HonorificPrefix + row[colSuffix] = name.HonorificSuffix + } + + row[colNickname] = card.Value(vcard.FieldNickname) + + // Parse emails + if fields, ok := card[vcard.FieldEmail]; ok { + field := card.Preferred(vcard.FieldEmail) + if field != nil { + row[colEmail] = field.Value + } + + for _, field := range fields { + typeField := field.Params.Get(vcard.ParamType) + switch typeField { + case "HOME": + row[colHomeEmail] = field.Value + case "WORK": + row[colWorkEmail] = field.Value + case "OTHER": + row[colOtherEmail] = field.Value + default: + if row[colEmail] == "" { + row[colEmail] = field.Value + } + } + } + + emails, _ := json.Marshal(card.Values(vcard.FieldEmail)) + row[colEmails] = string(emails) + } + + // Parse phones + phones := card.Values(vcard.FieldTelephone) + if len(phones) > 0 { + row[colPhone] = strings.Join(phones, ",") // First phone becomes primary + // TODO: Implement proper type checking for mobile/work phones + } + + row[colOrganization] = card.Value(vcard.FieldOrganization) + row[colTitle] = card.Value(vcard.FieldTitle) + row[colRole] = card.Value(vcard.FieldRole) + row[colBirthday] = card.Value(vcard.FieldBirthday) + row[colAnniversary] = card.Value(vcard.FieldAnniversary) + row[colNote] = card.Value(vcard.FieldNote) + row[colURL] = card.Value(vcard.FieldURL) + + categories, _ := json.Marshal(card.Categories()) + row[colCategories] = string(categories) + + // modified_at (REV field) + if rev, err := card.Revision(); err == nil { + row[colModifiedAt] = rev.Format(time.RFC3339) + } else { + row[colModifiedAt] = addressObj.ModTime.Format(time.RFC3339) + } + + // raw_vcard - we need to encode the card back to string + // For now, we'll use a placeholder + row[colRawVCard] = "" + + return row +} + +func updateVCardFromRow(card vcard.Card, row []any) error { + // Set formatted name + if fullName, ok := row[colFullName].(string); ok { + card.SetValue(vcard.FieldFormattedName, fullName) + } + + name := card.Name() + if name == nil { + name = &vcard.Name{} + } + if givenName, ok := row[colGivenName].(string); ok { + name.GivenName = givenName + } + if familyName, ok := row[colFamilyName].(string); ok { + name.FamilyName = familyName + } + if middleName, ok := row[colMiddleName].(string); ok { + name.AdditionalName = middleName + } + if prefix, ok := row[colPrefix].(string); ok { + name.HonorificPrefix = prefix + } + if suffix, ok := row[colSuffix].(string); ok { + name.HonorificSuffix = suffix + } + + // only set name if it's not empty + if *name != (vcard.Name{}) { + card.SetName(name) + } + + // Set nickname + if nickname, ok := row[colNickname].(string); ok { + card.SetValue(vcard.FieldNickname, nickname) + } + + // Set primary email emails + if email, ok := row[colEmail].(string); ok { + if preferedField := card.Preferred(vcard.FieldEmail); preferedField != nil { + // found preferred email, update it + preferedField.Value = email + } else if idx := slices.IndexFunc(card[vcard.FieldEmail], func(field *vcard.Field) bool { + return len(field.Params) == 0 + }); idx != -1 { + // found email without params, update it + card[vcard.FieldEmail][idx].Value = email + } else { + // no email without params, add prefered email + card.Add(vcard.FieldEmail, &vcard.Field{ + Value: email, + Params: vcard.Params{ + vcard.ParamPreferred: []string{"1"}, + }, + }) + } + } + if homeEmail, ok := row[colHomeEmail].(string); ok { + updateOrAddCardField(card, vcard.FieldEmail, &vcard.Field{ + Value: homeEmail, + Params: vcard.Params{ + vcard.ParamType: []string{"HOME"}, + }, + }) + } + if workEmail, ok := row[colWorkEmail].(string); ok { + updateOrAddCardField(card, vcard.FieldEmail, &vcard.Field{ + Value: workEmail, + Params: vcard.Params{ + vcard.ParamType: []string{"WORK"}, + }, + }) + } + if otherEmail, ok := row[colOtherEmail].(string); ok { + updateOrAddCardField(card, vcard.FieldEmail, &vcard.Field{ + Value: otherEmail, + Params: vcard.Params{ + vcard.ParamType: []string{"OTHER"}, + }, + }) + } + + // Set phones + if phone, ok := row[colPhone].(string); ok && phone != "" { + updateOrAddCardField(card, vcard.FieldTelephone, &vcard.Field{ + Value: phone, + }) + } + if mobilePhone, ok := row[colMobilePhone].(string); ok && mobilePhone != "" { + updateOrAddCardField(card, vcard.FieldTelephone, &vcard.Field{ + Value: mobilePhone, + Params: vcard.Params{ + vcard.ParamType: []string{"cell"}, + }, + }) + } + if workPhone, ok := row[colWorkPhone].(string); ok && workPhone != "" { + updateOrAddCardField(card, vcard.FieldTelephone, &vcard.Field{ + Value: workPhone, + Params: vcard.Params{ + vcard.ParamType: []string{"work"}, + }, + }) + } + + // Set other fields + if organization, ok := row[colOrganization].(string); ok { + card.SetValue(vcard.FieldOrganization, organization) + } + + if title, ok := row[colTitle].(string); ok { + card.SetValue(vcard.FieldTitle, title) + } + + if role, ok := row[colRole].(string); ok { + card.SetValue(vcard.FieldRole, role) + } + + if birthday, ok := row[colBirthday].(string); ok { + card.SetValue(vcard.FieldBirthday, birthday) + } + + if anniversary, ok := row[colAnniversary].(string); ok { + card.SetValue(vcard.FieldAnniversary, anniversary) + } + + if note, ok := row[colNote].(string); ok { + card.SetValue(vcard.FieldNote, note) + } + + if url, ok := row[colURL].(string); ok { + card.SetValue(vcard.FieldURL, url) + } + + if categories, ok := row[colCategories].(string); ok { + categoryList := strings.Split(categories, ",") + for i, cat := range categoryList { + categoryList[i] = strings.TrimSpace(cat) + } + card.SetCategories(categoryList) + } + + // Set revision to current time + card.SetRevision(time.Now()) + + return nil +} + +func matchesField(field, matchField *vcard.Field) bool { + for paramName, matchParamValues := range matchField.Params { + paramValues, ok := field.Params[paramName] + if !ok { + return false + } + + for _, val := range matchParamValues { + if !slices.Contains(paramValues, val) { + return false + } + } + } + + return true +} + +func updateOrAddCardField(card vcard.Card, name string, newField *vcard.Field) *vcard.Field { + fieldIndex := slices.IndexFunc(card[name], func(field *vcard.Field) bool { + return matchesField(field, newField) + }) + if fieldIndex != -1 { + field := card[name][fieldIndex] + + // Update field value + field.Value = newField.Value + return field + } + + // Add new field + card.Add(name, newField) + return newField +} diff --git a/plugins/carddav/contacts_test.go b/plugins/carddav/contacts_test.go new file mode 100644 index 0000000..97b9050 --- /dev/null +++ b/plugins/carddav/contacts_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "testing" + + "github.com/emersion/go-vcard" + "github.com/emersion/go-webdav/carddav" + "github.com/julien040/anyquery/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test contacts query +func TestContactsQuery(t *testing.T) { + server, _ := setupTestServer(t) + contactsTable, schema := createTestPlugin(t, server.URL) + defer contactsTable.Close() + + // Verify schema + assert.Len(t, schema.Columns, colCount, "Schema column count mismatch") + + // Test parameter requirement + cursor := contactsTable.CreateReader().(*contactsCursor) + emptyConstraints := rpc.QueryConstraint{} + + // Should fail without address book parameter + _, _, err := cursor.Query(emptyConstraints) + assert.Error(t, err, "Expected error when address_book parameter is missing") + + // Test with proper constraints using the actual cursor + constraints := rpc.QueryConstraint{ + Columns: []rpc.ColumnConstraint{ + { + ColumnID: colAddressBook, + Operator: rpc.OperatorEqual, + Value: "/addressbooks/user/personal/", + }, + }, + } + + rows, eof, err := cursor.Query(constraints) + require.NoError(t, err, "Failed to query contacts with cursor") + + assert.True(t, eof, "Expected EOF to be true") + assert.GreaterOrEqual(t, len(rows), 2, "Expected at least 2 contacts") + + // Verify contact row structure and data + for i, row := range rows { + assert.Len(t, row, colCount-paramCount, "Row %d column count mismatch", i) // Excluding parameter columns + + uid := row[colUID-paramCount].(string) + fullName := row[colFullName-paramCount].(string) + + assert.NotEmpty(t, uid, "Row %d UID should not be empty", i) + assert.NotEmpty(t, fullName, "Row %d full name should not be empty", i) + + // Verify row has proper path structure + path := row[colPath-paramCount].(string) + assert.Contains(t, path, ".vcf", "Row %d path should contain .vcf extension", i) + } +} + +// Test contact insertion +func TestContactInsert(t *testing.T) { + server, backend := setupTestServer(t) + contactsTable, _ := createTestPlugin(t, server.URL) + defer contactsTable.Close() + + // Create test row for insertion + testRow := make([]any, len(contactsSchema)) + testRow[colAddressBook] = "/addressbooks/user/personal/" + testRow[colUID] = "test-insert" + testRow[colFullName] = "Test Insert" + testRow[colEmail] = "test@insert.com" + testRow[colPhone] = "+1111111111" + testRow[colOrganization] = "Test Corp" + + // Insert the contact + err := contactsTable.Insert([][]any{testRow}) + require.NoError(t, err, "Failed to insert contact") + + // Verify it was added to backend + personalContacts := backend.contacts["/addressbooks/user/personal/"] + assert.Len(t, personalContacts, 3, "Expected 3 contacts after insert") + + // Verify the inserted contact + insertedContact, exists := personalContacts["test-insert"] + require.True(t, exists, "Inserted contact not found in backend") + assert.Equal(t, "Test Insert", insertedContact.Card.Value(vcard.FieldFormattedName)) + assert.Equal(t, "test@insert.com", insertedContact.Card.Value(vcard.FieldEmail)) +} + +// Test contact update +func TestContactUpdate(t *testing.T) { + server, backend := setupTestServer(t) + contactsTable, _ := createTestPlugin(t, server.URL) + defer contactsTable.Close() + + // Get existing contact + originalContact := backend.contacts["/addressbooks/user/personal/"]["test-1"] + require.NotNil(t, originalContact, "Test contact not found") + + // Create update row + updateRow := make([]any, len(contactsSchema)) + updateRow[colAddressBook] = "/addressbooks/user/personal/" + updateRow[colUID] = "test-1" // Primary key + updateRow[colFullName] = "John Doe Updated" + updateRow[colEmail] = "john.updated@example.com" + updateRow[colPhone] = "+9999999999" + updateRow[colOrganization] = "Updated Corp" + + // first column is primary key that's being updated + updatedRow := append([]any{"/addressbooks/user/personal/test-1.vcf"}, updateRow...) + + // Update the contact + err := contactsTable.Update([][]any{updatedRow}) + require.NoError(t, err, "Failed to update contact") + + // Verify the update + updatedContact := backend.contacts["/addressbooks/user/personal/"]["test-1"] + require.NotNil(t, updatedContact, "Updated contact not found") + + assert.Equal(t, "John Doe Updated", updatedContact.Card.Value(vcard.FieldFormattedName), "Contact full name was not updated") + assert.Equal(t, "john.updated@example.com", updatedContact.Card.Value(vcard.FieldEmail), "Contact email was not updated") + assert.Equal(t, "Updated Corp", updatedContact.Card.Value(vcard.FieldOrganization), "Contact organization was not updated") +} + +// Test contact deletion +func TestContactDelete(t *testing.T) { + server, backend := setupTestServer(t) + contactsTable, _ := createTestPlugin(t, server.URL) + defer contactsTable.Close() + + // Verify contact exists before deletion + require.NotNil(t, backend.contacts["/addressbooks/user/personal/"]["test-2"], "Test contact not found before deletion") + + // Delete is not fully implemented, so test the actual delete functionality + err := contactsTable.Delete([]any{"/addressbooks/user/personal/test-2.vcf"}) + require.NoError(t, err, "Delete operation should work") + + // Verify contact was deleted + assert.Nil(t, backend.contacts["/addressbooks/user/personal/"]["test-2"], "Contact was not deleted from backend") +} + +// Test error handling +func TestErrorHandling(t *testing.T) { + server, _ := setupTestServer(t) + contactsTable, _ := createTestPlugin(t, server.URL) + defer contactsTable.Close() + + ctx := context.Background() + query := &carddav.AddressBookQuery{ + DataRequest: carddav.AddressDataRequest{AllProp: true}, + } + + _, err := contactsTable.client.QueryAddressBook(ctx, "/invalid/addressbook/", query) + assert.Error(t, err, "Expected error for invalid address book") + + // Test insert without address book + invalidRow := make([]any, len(contactsSchema)) + invalidRow[colUID] = "test-invalid" + invalidRow[colFullName] = "Invalid Test" + // Missing address book + + err = contactsTable.Insert([][]any{invalidRow}) + assert.Error(t, err, "Expected error for insert without address book") + + // Test update without UID + invalidUpdateRow := make([]any, len(contactsSchema)) + invalidUpdateRow[colFullName] = "No UID Test" + invalidUpdateRow = append([]any{colAddressBook: "/addressbooks/user/personal/"}, invalidUpdateRow...) + // Missing UID + + err = contactsTable.Update([][]any{invalidUpdateRow}) + assert.Error(t, err, "Expected error for update without UID") +} + +// Test vCard parsing functionality +func TestVCardParsing(t *testing.T) { + // Create a test contact + contact := createTestContact("test-parse", "Dr. John Q. Doe Jr.", "john.doe@example.com", "+1-555-123-4567") + + // Parse to row + row := parseVCardToRow(contact) + + // Verify row length + assert.Len(t, row, colCount, "Row column count mismatch") + + // Verify specific fields + assert.Equal(t, "test-parse", row[colUID].(string), "UID mismatch") + assert.Equal(t, "Dr. John Q. Doe Jr.", row[colFullName].(string), "Full name mismatch") + assert.Equal(t, "john.doe@example.com", row[colEmail].(string), "Email mismatch") + assert.Equal(t, "+1-555-123-4567", row[colPhone].(string), "Phone mismatch") + + newCard := make(vcard.Card) + + // Test round-trip: row to vCard + err := updateVCardFromRow(newCard, row) + require.NoError(t, err, "Failed to create vCard from row") + + // Verify round-trip fields + assert.Equal(t, "Dr. John Q. Doe Jr.", newCard.Value(vcard.FieldFormattedName), "Round-trip full name mismatch") + assert.Equal(t, "john.doe@example.com", newCard.PreferredValue(vcard.FieldEmail), "Round-trip email mismatch") + assert.Equal(t, "+1-555-123-4567", newCard.Value(vcard.FieldTelephone), "Round-trip phone mismatch") +} diff --git a/plugins/carddav/go.mod b/plugins/carddav/go.mod new file mode 100644 index 0000000..84837aa --- /dev/null +++ b/plugins/carddav/go.mod @@ -0,0 +1,32 @@ +module github.com/julien040/anyquery/plugins/carddav + +go 1.24.4 + +require ( + github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 + github.com/emersion/go-webdav v0.6.0 + github.com/julien040/anyquery v0.1.6 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/grpc v1.74.2 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/carddav/go.sum b/plugins/carddav/go.sum new file mode 100644 index 0000000..21c0bf6 --- /dev/null +++ b/plugins/carddav/go.sum @@ -0,0 +1,86 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w= +github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/julien040/anyquery v0.1.6 h1:/Kw4zY2q9Fanq58Ohb7FQmHhIU4vicTqpDN6Tw+AApw= +github.com/julien040/anyquery v0.1.6/go.mod h1:UX7vuUZWWZsZ8EtQP5s0yVRTapzJhogF5P0P3rxrXFY= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/carddav/main.go b/plugins/carddav/main.go new file mode 100644 index 0000000..7f9d012 --- /dev/null +++ b/plugins/carddav/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/julien040/anyquery/rpc" +) + +func main() { + plugin := rpc.NewPlugin(contactsCreator, addressBooksCreator) + plugin.Serve() +} diff --git a/plugins/carddav/manifest.toml b/plugins/carddav/manifest.toml new file mode 100644 index 0000000..4503c2d --- /dev/null +++ b/plugins/carddav/manifest.toml @@ -0,0 +1,61 @@ + +name = "carddav" +version = "0.1.0" +description = "Query and manage CardDAV contacts with SQL" +author = "offlinehacker" +license = "UNLICENSED" +repository = "https://github.com/julien040/anyquery/tree/main/plugins/carddav" +homepage = "https://github.com/julien040/anyquery/tree/main/plugins/carddav" +type = "anyquery" +minimumAnyqueryVersion = "0.4.0" + +tables = ["contacts", "address_books"] + +# The user configuration schema +[[userConfig]] +name = "url" +description = "CardDAV server URL (e.g., https://carddav.example.com)" +type = "string" +required = true + +[[userConfig]] +name = "username" +description = "Username for CardDAV authentication" +type = "string" +required = true + +[[userConfig]] +name = "password" +description = "Password for CardDAV authentication" +type = "string" +required = true + +[[file]] +platform = "linux/amd64" +directory = "dist/anyquery_linux_amd64_v1" +executablePath = "carddav" + +[[file]] +platform = "linux/arm64" +directory = "dist/anyquery_linux_arm64" +executablePath = "carddav" + +[[file]] +platform = "darwin/amd64" +directory = "dist/anyquery_darwin_amd64_v1" +executablePath = "carddav" + +[[file]] +platform = "darwin/arm64" +directory = "dist/anyquery_darwin_arm64" +executablePath = "carddav" + +[[file]] +platform = "windows/amd64" +directory = "dist/anyquery_windows_amd64_v1" +executablePath = "carddav.exe" + +[[file]] +platform = "windows/arm64" +directory = "dist/anyquery_windows_arm64" +executablePath = "carddav.exe" diff --git a/plugins/carddav/test.sh b/plugins/carddav/test.sh new file mode 100755 index 0000000..ef53454 --- /dev/null +++ b/plugins/carddav/test.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# CardDAV Plugin Test Script for Nextcloud/CardDAV Servers +# Usage: +# export CARDDAV_URL="https://your-nextcloud.com/remote.php/dav/addressbooks/users/yourusername/" +# export CARDDAV_USERNAME="your_username" +# export CARDDAV_PASSWORD="your_password" +# ./test.sh + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test configuration +TIMESTAMP=$(date +%s) +TEST_CONTACT_UID="test-contact-${TIMESTAMP}" +TEST_CONTACT_NAME="Test Contact ${TIMESTAMP}" +TEST_CONTACT_EMAIL="test${TIMESTAMP}@example.com" +TEST_CONTACT_PHONE="+1555${TIMESTAMP: -4}" + +# Helper functions +print_header() { + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}========================================${NC}" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +print_info() { + echo -e "${YELLOW}ℹ $1${NC}" +} + +check_dev_manifest() { + if [[ ! -f "devManifest.json" ]]; then + print_error "devManifest.json not found" + echo "Make sure you're running this script from the plugin directory" + exit 1 + fi + + # Check if devManifest.json has credentials configured + if ! grep -q '"url"' devManifest.json || ! grep -q '"username"' devManifest.json || ! grep -q '"password"' devManifest.json; then + print_error "devManifest.json is missing required credentials (url, username, password)" + echo "Please configure your CardDAV credentials in devManifest.json" + exit 1 + fi +} + +execute_sql() { + local sql="$1" + # Load plugin and execute SQL in a single anyquery session + (echo "SELECT load_dev_plugin('carddav', 'devManifest.json');" && echo "$sql") | anyquery --dev +} + +# Main test execution +main() { + print_header "CardDAV Plugin Test Script" + + # Check devManifest.json + print_info "Checking devManifest.json..." + check_dev_manifest + print_success "devManifest.json found with credentials configured" + + # Build the plugin + print_info "Building CardDAV plugin..." + if make > /dev/null 2>&1; then + print_success "Plugin built successfully" + else + print_error "Failed to build plugin" + exit 1 + fi + + # Start anyquery and test + print_header "Starting CardDAV Plugin Tests" + + # Test 1: List address books + print_header "Test 1: List Address Books" + execute_sql "SELECT path, name, description FROM carddav_address_books;" + # Get address book info - the path will be empty for our setup + ADDRESS_BOOK_FULL_RESULT=$(execute_sql "SELECT * FROM carddav_address_books LIMIT 1;" 2>&1) + + # Check if we got the address book data (look for "Contacts") + if echo "$ADDRESS_BOOK_FULL_RESULT" | grep -q "Contacts"; then + # Extract the actual path from the result - get the first one which should be "Contacts" + ADDRESS_BOOK_PATH=$(echo "$ADDRESS_BOOK_FULL_RESULT" | grep "contacts/" | head -1 | sed 's/\t.*//g' | xargs) + print_success "Found address book: Contacts (path: '$ADDRESS_BOOK_PATH')" + else + print_error "No address books found in result: $ADDRESS_BOOK_FULL_RESULT" + exit 1 + fi + + # Test 2: List contacts from first address book + print_header "Test 2: List Contacts from Address Book" + execute_sql "SELECT COUNT(*) as contact_count FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH';" + execute_sql "SELECT uid, full_name, email, phone FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH' LIMIT 5;" + + # Test 4: Insert a new contact + print_header "Test 3: Insert New Contact" + INSERT_SQL="INSERT INTO carddav_contacts (address_book, uid, full_name, email, phone, organization) + VALUES ('$ADDRESS_BOOK_PATH', '$TEST_CONTACT_UID', '$TEST_CONTACT_NAME', '$TEST_CONTACT_EMAIL', '$TEST_CONTACT_PHONE', 'Test Corp');" + + print_info "Inserting contact: $TEST_CONTACT_NAME" + if execute_sql "$INSERT_SQL" > /dev/null 2>&1; then + print_success "Contact inserted successfully" + else + print_error "Failed to insert contact" + exit 1 + fi + + # Verify insertion + print_info "Verifying insertion..." + execute_sql "SELECT uid, full_name, email, phone, organization FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH' AND uid = '$TEST_CONTACT_UID';" + + # Test 5: Update the contact + print_header "Test 4: Update Contact" + UPDATE_SQL="UPDATE carddav_contacts + SET full_name = '${TEST_CONTACT_NAME} Updated', + email = 'updated${TIMESTAMP}@example.com', + organization = 'Updated Corp' + WHERE address_book = '$ADDRESS_BOOK_PATH' AND uid = '$TEST_CONTACT_UID';" + + print_info "Updating contact: $TEST_CONTACT_UID" + if execute_sql "$UPDATE_SQL" > /dev/null 2>&1; then + print_success "Contact updated successfully" + else + print_error "Failed to update contact" + fi + + # Verify update + print_info "Verifying update..." + execute_sql "SELECT uid, full_name, email, phone, organization FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH' AND uid = '$TEST_CONTACT_UID';" + + # Test 6: Attempt to delete the contact (should show expected error) + print_header "Test 5: Delete Contact (Expected to show 'not implemented' error)" + DELETE_SQL="DELETE FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH' AND uid = '$TEST_CONTACT_UID';" + + print_info "Attempting to delete contact: $TEST_CONTACT_UID" + if execute_sql "$DELETE_SQL" 2>&1; then + print_info "Delete operation completed (may show 'not implemented' message)" + else + print_info "Delete operation failed as expected (not implemented)" + fi + + # Final verification - check if contact still exists + print_info "Final verification - checking if contact still exists..." + CONTACT_COUNT=$(execute_sql "SELECT COUNT(*) FROM carddav_contacts WHERE address_book = '$ADDRESS_BOOK_PATH' AND uid = '$TEST_CONTACT_UID';" | tail -n +2 | tr -d ' ') + + if [[ "$CONTACT_COUNT" == "1" ]]; then + print_info "Test contact still exists (delete not implemented, as expected)" + else + print_info "Test contact was removed" + fi + + print_header "Test Summary" + print_success "Plugin loading: PASSED" + print_success "Address book listing: PASSED" + print_success "Contact querying: PASSED" + print_success "Contact insertion: PASSED" + print_success "Contact update: PASSED" + print_info "Contact deletion: EXPECTED BEHAVIOR (not implemented)" + + print_header "All Tests Completed!" + echo -e "${GREEN}The CardDAV plugin is working correctly with your server.${NC}" + echo -e "${YELLOW}Note: Test contact '$TEST_CONTACT_UID' may still exist in your address book.${NC}" +} + +# Run main function +main "$@"