1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::dav_common::actions::{action, parameter};
8use crate::server::dav_common::{client as engine, server::Kind};
9use anyhow::Result;
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct CardDavClientProtocol;
15impl CardDavClientProtocol {
16 pub const fn new() -> Self {
17 Self
18 }
19}
20
21pub static PROTOCOL: CardDavClientProtocol = CardDavClientProtocol;
22
23fn coll() -> crate::llm::actions::Parameter {
24 parameter(
25 "collection",
26 "string",
27 "Address book name under the home (as listed on connect) or its absolute path",
28 true,
29 )
30}
31fn name() -> crate::llm::actions::Parameter {
32 parameter("name", "string", "Object file name, e.g. ada.vcf", true)
33}
34fn actions() -> Vec<ActionDefinition> {
35 vec![
36 action("carddav_list", "List a address book's objects with their ETags (PROPFIND Depth 1)", vec![coll()], json!({"type":"carddav_list","collection":"contacts"})),
37 action("carddav_get", "Fetch one object (GET)", vec![coll(), name()], json!({"type":"carddav_get","collection":"contacts","name":"ada.vcf"})),
38 action(
39 "carddav_put",
40 "Create or replace an object (PUT); Rust checks the vCard text before sending",
41 vec![coll(), name(), parameter("data", "string", "vCard text, e.g. BEGIN:VCARD … VERSION:3.0 … UID … FN … END:VCARD", true), parameter("if_match", "string", "ETag the object must still have (update without overwriting someone else's change)", false), parameter("create_only", "boolean", "Send If-None-Match: * so an existing object is not overwritten", false)],
42 json!({"type":"carddav_put","collection":"contacts","name":"ada.vcf","data":"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ada\r\nFN:Ada Lovelace\r\nEMAIL:ada@example.com\r\nEND:VCARD\r\n","create_only":true}),
43 ),
44 action("carddav_delete", "Delete an object, or the whole address book when name is omitted", vec![coll(), parameter("name", "string", "Object file name; omit to delete the address book", false), parameter("if_match", "string", "ETag the object must still have", false)], json!({"type":"carddav_delete","collection":"contacts","name":"ada.vcf"})),
45 action("carddav_query", "Find cards whose property matches text (addressbook-query REPORT); data comes back with them", vec![coll(), parameter("property", "string", "vCard property to match, e.g. FN or EMAIL (default FN)", false), parameter("text", "string", "Text to look for", true), parameter("match_type", "string", "contains (default), equals, starts-with or ends-with", false)], json!({"type":"carddav_query","collection":"contacts","property":"EMAIL","text":"example.com"})),
46 action("carddav_make_collection", "Create a address book under the home (extended MKCOL)", vec![coll(), parameter("displayname", "string", "Display name for the new address book", false)], json!({"type":"carddav_make_collection","collection":"contacts2","displayname":"Second"})),
47 action("disconnect", "Stop this CardDAV client", vec![], json!({"type":"disconnect"})),
48 ]
49}
50
51pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
52 EventType::new(
53 "carddav_connected",
54 "Discovery finished: the principal, the home and its address books",
55 json!({"type":"carddav_list","collection":"contacts"}),
56 )
57 .with_parameters(vec![
58 parameter(
59 "principal",
60 "string",
61 "Path of the current user's principal",
62 true,
63 ),
64 parameter("home", "string", "Path of the address book home", true),
65 parameter(
66 "collections",
67 "array",
68 "[{href, name, displayname, ctag}] for every address book in the home",
69 true,
70 ),
71 ])
72 .with_actions(actions())
73});
74pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
75 EventType::new(
76 "carddav_response",
77 "The server's answer to the last operation",
78 json!({"type":"disconnect"}),
79 )
80 .with_parameters(vec![
81 parameter(
82 "operation",
83 "string",
84 "list, get, put, delete, query or make_collection",
85 true,
86 ),
87 parameter(
88 "status",
89 "number",
90 "HTTP status of the answer (207 for list and query)",
91 true,
92 ),
93 parameter(
94 "objects",
95 "array",
96 "For list and query: [{href, name, etag, data (query only)}]",
97 false,
98 ),
99 parameter("data", "string", "For get: the object text", false),
100 parameter(
101 "etag",
102 "string",
103 "For get and put: the object's entity tag",
104 false,
105 ),
106 parameter(
107 "error",
108 "string",
109 "For a refused put: the server's error body",
110 false,
111 ),
112 ])
113 .with_actions(actions())
114});
115
116impl Protocol for CardDavClientProtocol {
117 fn protocol_name(&self) -> &'static str {
118 "CardDAV"
119 }
120 fn stack_name(&self) -> &'static str {
121 "ETH>IP>TCP>HTTP>WebDAV>CardDAV"
122 }
123 fn keywords(&self) -> Vec<&'static str> {
124 vec!["carddav", "contacts client", "vcard"]
125 }
126 fn description(&self) -> &'static str {
127 "CardDAV client: discovers the user's address books, lists, queries, reads, writes and deletes objects"
128 }
129 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
130 actions()
131 }
132 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
133 vec![]
134 }
135 fn get_event_types(&self) -> Vec<EventType> {
136 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
137 }
138 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
139 let p =
140 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
141 ParameterDefinition {
142 name: name.into(),
143 type_hint: kind.into(),
144 description: description.into(),
145 required: false,
146 example,
147 default,
148 }
149 };
150 vec![
151 p(
152 "scheme",
153 "string",
154 "URL scheme to reach the server with: https (default) or http",
155 json!("http"),
156 Some(json!(engine::DEFAULT_SCHEME)),
157 ),
158 p(
159 "username",
160 "string",
161 "User name for HTTP Basic authentication",
162 json!("alice"),
163 None,
164 ),
165 p(
166 "password",
167 "string",
168 "Password for HTTP Basic authentication",
169 json!("secret"),
170 None,
171 ),
172 ]
173 }
174 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
175 use crate::protocol::metadata::*;
176 ProtocolMetadataV2::builder()
177 .state(DevelopmentState::Experimental)
178 .privilege_requirement(PrivilegeRequirement::None)
179 .implementation("Shared http_fetch client (reqwest natively, redirects followed by hand for discovery only); RFC 6764 well-known discovery, PROPFIND, addressbook-query REPORT, multistatus parsing, If-Match / If-None-Match, extended MKCOL; objects validated with the server's parser before PUT")
180 .llm_control("Which address books and objects to read, query, write or delete")
181 .e2e_testing("tests/client/carddav: Radicale 3.8.1 (independent) answers discovery, collection creation, PUT, list, query, GET, conditional PUT and DELETE")
182 .notes("No sync-collection or scheduling; one request at a time.")
183 .max_inbound_bytes(crate::server::dav_common::xml::MAX_BODY * 4)
184 .build()
185 }
186 fn example_prompt(&self) -> &'static str {
187 "Find Ada's email in the CardDAV address book at 127.0.0.1:5232"
188 }
189 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
190 let llm = json!({"type":"open_client","protocol":"carddav","remote_addr":"127.0.0.1:5232","instruction":"Find everyone at example.com","startup_params":{"scheme":"http","username":"alice","password":"secret"}});
191 let mut static_example = llm.clone();
192 static_example["event_handlers"] = json!([
193 {"event_pattern":"carddav_connected","handler":{"type":"static","actions":[{"type":"carddav_list","collection":"contacts"}]}},
194 {"event_pattern":"carddav_response","handler":{"type":"static","actions":[]}}
195 ]);
196 let mut scripted = static_example.clone();
197 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
198 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
199 }
200 fn group_name(&self) -> &'static str {
201 "Web & File"
202 }
203}
204
205impl Client for CardDavClientProtocol {
206 fn connect(
207 &self,
208 ctx: ConnectContext,
209 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
210 {
211 Box::pin(engine::connect(ctx, &super::FLAVOR))
212 }
213 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
214 engine::check_action("carddav", Kind::AddressBook, &v)
215 }
216}