Skip to content

Commit 54bb289

Browse files
authored
🔀 Merge pull request #59 from offlinehacker/carddav
✨ Add carddav plugin
2 parents 0eccc5a + 9ea3614 commit 54bb289

15 files changed

+2043
-0
lines changed

‎plugins/carddav/.gitignore‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
# Go template downloaded with gut
3+
*.exe
4+
*.exe~
5+
*.dll
6+
*.so
7+
*.dylib
8+
*.test
9+
*.out
10+
go.work
11+
.gut
12+
13+
# Dev files
14+
*.log
15+
devManifest.*
16+
.init
17+
18+
dist/
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
2+
version: 2
3+
4+
before:
5+
hooks:
6+
# You may remove this if you don't use go modules.
7+
- go mod tidy
8+
9+
builds:
10+
- env:
11+
- CGO_ENABLED=0
12+
goos:
13+
- linux
14+
- windows
15+
- darwin
16+
binary: carddav
17+
id: anyquery
18+
ldflags: "-s -w"
19+
flags: # To ensure reproducible builds
20+
- -trimpath
21+
22+
goarch:
23+
- amd64
24+
- arm64
25+
26+
archives:
27+
- format: binary
28+
29+
changelog:
30+
sort: asc
31+
filters:
32+
exclude:
33+
- "^docs:"
34+
- "^test:"

‎plugins/carddav/Makefile‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
2+
files := $(wildcard *.go)
3+
4+
all: $(files)
5+
go build -o carddav.out $(files)
6+
7+
prod: $(files)
8+
go build -o carddav.out -ldflags "-s -w" $(files)
9+
10+
release: prod
11+
goreleaser build -f .goreleaser.yaml --clean --snapshot
12+
13+
test:
14+
go test -v ./...
15+
16+
integration-test:
17+
./test.sh
18+
19+
clean:
20+
rm -f carddav.out
21+
22+
.PHONY: all clean test integration-test

‎plugins/carddav/README.md‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# CardDAV plugin
2+
3+
Query and manage CardDAV contacts with SQL.
4+
5+
## Usage
6+
7+
```sql
8+
-- List all available address books
9+
SELECT * FROM carddav_address_books;
10+
11+
-- Get all contacts from a CardDAV address book
12+
SELECT * FROM carddav_contacts WHERE address_book = 'contacts/';
13+
14+
-- Search for contacts by name
15+
SELECT full_name, email, phone FROM carddav_contacts
16+
WHERE address_book = 'contacts/' AND full_name LIKE '%John%';
17+
18+
-- Insert a new contact
19+
INSERT INTO carddav_contacts (address_book, uid, full_name, email, phone)
20+
VALUES ('contacts/', 'unique-id-123', 'John Doe', '[email protected]', '+1234567890');
21+
22+
-- Update a contact
23+
UPDATE carddav_contacts
24+
SET email = '[email protected]', organization = 'New Company'
25+
WHERE address_book = 'contacts/' AND uid = 'unique-id-123';
26+
```
27+
28+
## Installation
29+
30+
```bash
31+
anyquery install carddav
32+
```
33+
34+
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.
35+
36+
### Popular CardDAV Providers
37+
38+
#### Nextcloud
39+
40+
```txt
41+
URL: https://your-nextcloud.com/remote.php/dav/addressbooks/users/yourusername/
42+
```
43+
44+
Create an app-specific password in Settings → Security → App passwords.
45+
46+
#### Google Contacts
47+
48+
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).
49+
50+
#### Apple iCloud
51+
52+
```txt
53+
URL: https://contacts.icloud.com/
54+
Username: The email used by your Apple account
55+
Password: Your Apple ID password
56+
```
57+
58+
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.)
59+
60+
## Tables
61+
62+
### `carddav_address_books`
63+
64+
List available address books on the CardDAV server.
65+
66+
#### Schema
67+
68+
| Column index | Column name | Type | Description |
69+
| ------------ | ----------------- | ------- | ----------------------------------- |
70+
| 0 | path | TEXT | Address book path (use for queries) |
71+
| 1 | name | TEXT | Display name of the address book |
72+
| 2 | description | TEXT | Description of the address book |
73+
| 3 | max_resource_size | INTEGER | Maximum resource size |
74+
75+
### `carddav_contacts`
76+
77+
Query and manage contacts from CardDAV address books.
78+
79+
#### Schema
80+
81+
| Column index | Column name | Type | Description |
82+
| ------------ | ------------ | ---- | ----------------------------- |
83+
| 0 | address_book | TEXT | Address book path (parameter) |
84+
| 1 | uid | TEXT | Unique identifier |
85+
| 2 | etag | TEXT | ETag for conflict detection |
86+
| 3 | path | TEXT | CardDAV resource path |
87+
| 4 | full_name | TEXT | Full display name |
88+
| 5 | given_name | TEXT | First name |
89+
| 6 | family_name | TEXT | Last name |
90+
| 7 | middle_name | TEXT | Middle name |
91+
| 8 | prefix | TEXT | Name prefix (Mr., Dr., etc.) |
92+
| 9 | suffix | TEXT | Name suffix (Jr., Sr., etc.) |
93+
| 10 | nickname | TEXT | Nickname |
94+
| 11 | email | TEXT | Primary email address |
95+
| 12 | home_email | TEXT | Home email address |
96+
| 13 | work_email | TEXT | Work email address |
97+
| 14 | other_email | TEXT | Other email address |
98+
| 15 | emails | TEXT | All emails (JSON array) |
99+
| 16 | phone | TEXT | Primary phone number |
100+
| 17 | mobile_phone | TEXT | Mobile phone number |
101+
| 18 | work_phone | TEXT | Work phone number |
102+
| 19 | organization | TEXT | Organization/Company |
103+
| 20 | title | TEXT | Job title |
104+
| 21 | role | TEXT | Role/Position |
105+
| 22 | birthday | TEXT | Birthday (YYYY-MM-DD) |
106+
| 23 | anniversary | TEXT | Anniversary (YYYY-MM-DD) |
107+
| 24 | note | TEXT | Notes |
108+
| 25 | url | TEXT | Website URL |
109+
| 26 | categories | TEXT | Categories (JSON array) |
110+
| 27 | modified_at | TEXT | Last modified timestamp |
111+
112+
## Development
113+
114+
To develop and test the CardDAV plugin:
115+
116+
```bash
117+
cd plugins/carddav
118+
make
119+
make test # Run unit tests
120+
make integration-test # Run integration tests with real CardDAV server
121+
```
122+
123+
For manual testing, start anyquery in dev mode and load the plugin:
124+
125+
```bash
126+
anyquery --dev
127+
```
128+
129+
```sql
130+
SELECT load_dev_plugin('carddav', 'devManifest.json');
131+
```
132+
133+
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.
134+
135+
## Limitations
136+
137+
- Address book creation and deletion are not supported yet
138+
- Some CardDAV servers may have different URL formats or authentication requirements
139+
- Large contact lists may take time to query due to CardDAV protocol limitations
140+
- The plugin does not cache data - each query hits the CardDAV server directly
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/emersion/go-webdav/carddav"
8+
"github.com/julien040/anyquery/rpc"
9+
)
10+
11+
// Column indices for address_books table
12+
const (
13+
addrBookColPath = iota
14+
addrBookColName
15+
addrBookColDescription
16+
addrBookColMaxResourceSize
17+
18+
// count
19+
addrBookColCount
20+
)
21+
22+
var addressBookSchema = []rpc.DatabaseSchemaColumn{
23+
addrBookColPath: {
24+
Name: "path",
25+
Type: rpc.ColumnTypeString,
26+
Description: "Address book path (use this for contacts queries)",
27+
},
28+
addrBookColName: {
29+
Name: "name",
30+
Type: rpc.ColumnTypeString,
31+
Description: "Display name of the address book",
32+
},
33+
addrBookColDescription: {
34+
Name: "description",
35+
Type: rpc.ColumnTypeString,
36+
Description: "Description of the address book",
37+
},
38+
addrBookColMaxResourceSize: {
39+
Name: "max_resource_size",
40+
Type: rpc.ColumnTypeInt,
41+
Description: "Maximum resource size",
42+
},
43+
}
44+
45+
func addressBooksCreator(args rpc.TableCreatorArgs) (rpc.Table, *rpc.DatabaseSchema, error) {
46+
client, err := newCardDAVClient(args.UserConfig)
47+
if err != nil {
48+
return nil, nil, fmt.Errorf("failed to create CardDAV client: %w", err)
49+
}
50+
51+
return &addressBooksTable{client: client}, &rpc.DatabaseSchema{
52+
Columns: addressBookSchema,
53+
}, nil
54+
}
55+
56+
type addressBooksTable struct {
57+
client *carddav.Client
58+
}
59+
60+
type addressBooksCursor struct {
61+
tbl *addressBooksTable
62+
}
63+
64+
func (t *addressBooksTable) CreateReader() rpc.ReaderInterface {
65+
return &addressBooksCursor{tbl: t}
66+
}
67+
68+
func (t *addressBooksTable) Close() error {
69+
return nil
70+
}
71+
72+
func (c *addressBooksCursor) Query(constraints rpc.QueryConstraint) ([][]any, bool, error) {
73+
ctx := context.Background()
74+
75+
var addressBooks []carddav.AddressBook
76+
var err error
77+
78+
// Method 1: Try standard CardDAV discovery
79+
principal, err := c.tbl.client.FindAddressBookHomeSet(ctx, "")
80+
if err == nil {
81+
addressBooks, err = c.tbl.client.FindAddressBooks(ctx, principal)
82+
if err == nil && len(addressBooks) > 0 {
83+
// Success! Found address books via discovery
84+
} else {
85+
// Method 2: Try finding address books from root
86+
addressBooks, err = c.tbl.client.FindAddressBooks(ctx, "/")
87+
if err != nil || len(addressBooks) == 0 {
88+
// Method 3: Try finding address books from the current user's principal
89+
userPrincipal, userErr := c.tbl.client.FindCurrentUserPrincipal(ctx)
90+
if userErr == nil {
91+
homeSet, homeErr := c.tbl.client.FindAddressBookHomeSet(ctx, userPrincipal)
92+
if homeErr == nil {
93+
addressBooks, err = c.tbl.client.FindAddressBooks(ctx, homeSet)
94+
}
95+
}
96+
}
97+
}
98+
} else {
99+
// Method 2: Try finding address books from root
100+
addressBooks, err = c.tbl.client.FindAddressBooks(ctx, "/")
101+
if err != nil || len(addressBooks) == 0 {
102+
// Method 3: Try finding address books from the current user's principal
103+
userPrincipal, userErr := c.tbl.client.FindCurrentUserPrincipal(ctx)
104+
if userErr == nil {
105+
homeSet, homeErr := c.tbl.client.FindAddressBookHomeSet(ctx, userPrincipal)
106+
if homeErr == nil {
107+
addressBooks, err = c.tbl.client.FindAddressBooks(ctx, homeSet)
108+
}
109+
}
110+
}
111+
}
112+
113+
// If all discovery methods failed, return an error
114+
if len(addressBooks) == 0 {
115+
return nil, true, fmt.Errorf("failed to discover address books using multiple methods. Check your CardDAV URL and credentials")
116+
}
117+
118+
rows := make([][]any, len(addressBooks))
119+
for i, book := range addressBooks {
120+
row := make([]any, len(addressBookSchema))
121+
row[addrBookColPath] = book.Path
122+
row[addrBookColName] = book.Name
123+
row[addrBookColDescription] = book.Description
124+
row[addrBookColMaxResourceSize] = book.MaxResourceSize
125+
rows[i] = row
126+
}
127+
128+
return rows, true, nil
129+
}

0 commit comments

Comments
 (0)