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 "$@"