dav-mcp 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/package.json +80 -0
- package/src/error-handler.js +215 -0
- package/src/formatters.js +754 -0
- package/src/logger.js +144 -0
- package/src/server-http.js +402 -0
- package/src/server-stdio.js +225 -0
- package/src/tool-call-logger.js +148 -0
- package/src/tools/calendar/calendar-multi-get.js +38 -0
- package/src/tools/calendar/calendar-query.js +98 -0
- package/src/tools/calendar/create-event.js +79 -0
- package/src/tools/calendar/delete-calendar.js +36 -0
- package/src/tools/calendar/delete-event.js +38 -0
- package/src/tools/calendar/index.js +16 -0
- package/src/tools/calendar/list-calendars.js +21 -0
- package/src/tools/calendar/list-events.js +43 -0
- package/src/tools/calendar/make-calendar.js +80 -0
- package/src/tools/calendar/update-calendar.js +106 -0
- package/src/tools/calendar/update-event-fields.js +119 -0
- package/src/tools/calendar/update-event-raw.js +45 -0
- package/src/tools/contacts/addressbook-multi-get.js +38 -0
- package/src/tools/contacts/addressbook-query.js +85 -0
- package/src/tools/contacts/create-contact.js +84 -0
- package/src/tools/contacts/delete-contact.js +38 -0
- package/src/tools/contacts/index.js +13 -0
- package/src/tools/contacts/list-addressbooks.js +21 -0
- package/src/tools/contacts/list-contacts.js +32 -0
- package/src/tools/contacts/update-contact-fields.js +135 -0
- package/src/tools/contacts/update-contact-raw.js +45 -0
- package/src/tools/index.js +57 -0
- package/src/tools/shared/helpers.js +132 -0
- package/src/tools/todos/create-todo.js +101 -0
- package/src/tools/todos/delete-todo.js +38 -0
- package/src/tools/todos/index.js +12 -0
- package/src/tools/todos/list-todos.js +30 -0
- package/src/tools/todos/todo-multi-get.js +37 -0
- package/src/tools/todos/todo-query.js +112 -0
- package/src/tools/todos/update-todo-fields.js +119 -0
- package/src/tools/todos/update-todo-raw.js +46 -0
- package/src/tsdav-client.js +199 -0
- package/src/utils/tool-helpers.js +388 -0
- package/src/validation.js +245 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper functions for tsdav MCP tools
|
|
3
|
+
* This module eliminates code duplication across calendar, contact, and todo operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Escapes special XML characters to prevent injection attacks
|
|
8
|
+
* @param {string} text - Text to escape
|
|
9
|
+
* @returns {string} XML-safe string
|
|
10
|
+
*/
|
|
11
|
+
function escapeXml(text) {
|
|
12
|
+
if (!text) return '';
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates and retrieves a calendar by URL
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} client - The CalDAV client instance
|
|
25
|
+
* @param {string} calendarUrl - The URL of the calendar to find
|
|
26
|
+
* @returns {Promise<Object>} The validated calendar object
|
|
27
|
+
* @throws {Error} If calendar is not found with helpful error message
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const calendar = await getValidatedCalendar(client, 'https://dav.example.com/cal/user/work/');
|
|
31
|
+
*/
|
|
32
|
+
export async function getValidatedCalendar(client, calendarUrl) {
|
|
33
|
+
const calendars = await client.fetchCalendars();
|
|
34
|
+
const calendar = calendars.find(c => c.url === calendarUrl);
|
|
35
|
+
|
|
36
|
+
if (!calendar) {
|
|
37
|
+
const availableUrls = calendars.map(c => c.url).join('\n- ');
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Calendar not found: ${calendarUrl}\n\n` +
|
|
40
|
+
`Available calendar URLs:\n- ${availableUrls}\n\n` +
|
|
41
|
+
`Please use list_calendars first to get the correct calendar URLs.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return calendar;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validates and retrieves an address book by URL
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} client - The CardDAV client instance
|
|
52
|
+
* @param {string} addressBookUrl - The URL of the address book to find
|
|
53
|
+
* @returns {Promise<Object>} The validated address book object
|
|
54
|
+
* @throws {Error} If address book is not found with helpful error message
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const addressBook = await getValidatedAddressBook(client, 'https://dav.example.com/card/user/contacts/');
|
|
58
|
+
*/
|
|
59
|
+
export async function getValidatedAddressBook(client, addressBookUrl) {
|
|
60
|
+
const addressBooks = await client.fetchAddressBooks();
|
|
61
|
+
const addressBook = addressBooks.find(ab => ab.url === addressBookUrl);
|
|
62
|
+
|
|
63
|
+
if (!addressBook) {
|
|
64
|
+
const availableUrls = addressBooks.map(ab => ab.url).join('\n- ');
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Address book not found: ${addressBookUrl}\n\n` +
|
|
67
|
+
`Available address book URLs:\n- ${availableUrls}\n\n` +
|
|
68
|
+
`Please use list_addressbooks first to get the correct address book URLs.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return addressBook;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Builds time range options for CalDAV queries
|
|
77
|
+
* If only start date is provided, defaults end date to 1 year from start
|
|
78
|
+
*
|
|
79
|
+
* @param {string|undefined} startDate - Optional start date in ISO 8601 format
|
|
80
|
+
* @param {string|undefined} endDate - Optional end date in ISO 8601 format
|
|
81
|
+
* @returns {Object} Options object with timeRange property (or empty object if no dates)
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Both dates provided
|
|
85
|
+
* buildTimeRangeOptions('2025-01-01T00:00:00.000Z', '2025-12-31T23:59:59.000Z')
|
|
86
|
+
* // Returns: { timeRange: { start: '2025-01-01...', end: '2025-12-31...' } }
|
|
87
|
+
*
|
|
88
|
+
* // Only start date (end = start + 1 year)
|
|
89
|
+
* buildTimeRangeOptions('2025-01-01T00:00:00.000Z', undefined)
|
|
90
|
+
* // Returns: { timeRange: { start: '2025-01-01...', end: '2026-01-01...' } }
|
|
91
|
+
*
|
|
92
|
+
* // No dates
|
|
93
|
+
* buildTimeRangeOptions(undefined, undefined)
|
|
94
|
+
* // Returns: {}
|
|
95
|
+
*/
|
|
96
|
+
export function buildTimeRangeOptions(startDate, endDate) {
|
|
97
|
+
// No time range specified
|
|
98
|
+
if (!startDate) {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Only start date provided - default end to 1 year from start
|
|
103
|
+
if (!endDate) {
|
|
104
|
+
const start = new Date(startDate);
|
|
105
|
+
const end = new Date(start);
|
|
106
|
+
end.setFullYear(end.getFullYear() + 1);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
timeRange: {
|
|
110
|
+
start: startDate,
|
|
111
|
+
end: end.toISOString(),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Both dates provided
|
|
117
|
+
return {
|
|
118
|
+
timeRange: {
|
|
119
|
+
start: startDate,
|
|
120
|
+
end: endDate,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Searches multiple calendars and aggregates results with calendar name annotations
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} client - The CalDAV client instance
|
|
129
|
+
* @param {Array<Object>} calendars - Array of calendar objects to search
|
|
130
|
+
* @param {Object} fetchOptions - Options to pass to fetchCalendarObjects (e.g., timeRange)
|
|
131
|
+
* @returns {Promise<Array>} All events/todos from all calendars with _calendarName property
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* const calendars = [cal1, cal2, cal3];
|
|
135
|
+
* const events = await searchMultipleCalendars(client, calendars, { timeRange: {...} });
|
|
136
|
+
* // Each event has event._calendarName = "Work Calendar" etc.
|
|
137
|
+
*/
|
|
138
|
+
export async function searchMultipleCalendars(client, calendars, fetchOptions = {}) {
|
|
139
|
+
let allItems = [];
|
|
140
|
+
|
|
141
|
+
for (const calendar of calendars) {
|
|
142
|
+
const options = { calendar, ...fetchOptions };
|
|
143
|
+
const items = await client.fetchCalendarObjects(options);
|
|
144
|
+
|
|
145
|
+
// Add calendar info to each item for display
|
|
146
|
+
items.forEach(item => {
|
|
147
|
+
item._calendarName = calendar.displayName || calendar.url;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
allItems = allItems.concat(items);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return allItems;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Searches multiple calendars for todos and aggregates results
|
|
158
|
+
*
|
|
159
|
+
* @param {Object} client - The CalDAV client instance
|
|
160
|
+
* @param {Array<Object>} calendars - Array of calendar objects to search
|
|
161
|
+
* @returns {Promise<Array>} All todos from all calendars with _calendarName property
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const calendars = [cal1, cal2];
|
|
165
|
+
* const todos = await searchMultipleTodoCalendars(client, calendars);
|
|
166
|
+
*/
|
|
167
|
+
export async function searchMultipleTodoCalendars(client, calendars) {
|
|
168
|
+
let allTodos = [];
|
|
169
|
+
|
|
170
|
+
for (const calendar of calendars) {
|
|
171
|
+
const todos = await client.fetchTodos({ calendar });
|
|
172
|
+
|
|
173
|
+
// Add calendar info to each todo
|
|
174
|
+
todos.forEach(todo => {
|
|
175
|
+
todo._calendarName = calendar.displayName || calendar.url;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
allTodos = allTodos.concat(todos);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return allTodos;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Builds WebDAV PROPPATCH XML for updating calendar properties
|
|
186
|
+
* Uses proper XML escaping to prevent injection attacks
|
|
187
|
+
*
|
|
188
|
+
* @param {Object} properties - Object with optional display_name, description, color, timezone
|
|
189
|
+
* @returns {string} Complete PROPPATCH XML string
|
|
190
|
+
* @throws {Error} If timezone format is invalid
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* const xml = buildPropPatchXml({
|
|
194
|
+
* display_name: 'My Calendar',
|
|
195
|
+
* description: 'Work & Projects',
|
|
196
|
+
* color: '#FF5733',
|
|
197
|
+
* timezone: 'Europe/Berlin'
|
|
198
|
+
* });
|
|
199
|
+
*/
|
|
200
|
+
export function buildPropPatchXml(properties) {
|
|
201
|
+
const { display_name, description, color, timezone } = properties;
|
|
202
|
+
|
|
203
|
+
// Validate timezone format if provided
|
|
204
|
+
if (timezone && !timezone.includes('/')) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Invalid timezone format: ${timezone}. ` +
|
|
207
|
+
`Expected format: "Europe/Berlin", "America/New_York", etc.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
212
|
+
xml += '<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">\n';
|
|
213
|
+
xml += ' <d:set>\n';
|
|
214
|
+
xml += ' <d:prop>\n';
|
|
215
|
+
|
|
216
|
+
if (display_name) {
|
|
217
|
+
xml += ` <d:displayname>${escapeXml(display_name)}</d:displayname>\n`;
|
|
218
|
+
}
|
|
219
|
+
if (description) {
|
|
220
|
+
xml += ` <c:calendar-description>${escapeXml(description)}</c:calendar-description>\n`;
|
|
221
|
+
}
|
|
222
|
+
if (color) {
|
|
223
|
+
xml += ` <x:calendar-color>${escapeXml(color)}</x:calendar-color>\n`;
|
|
224
|
+
}
|
|
225
|
+
if (timezone) {
|
|
226
|
+
xml += ` <c:calendar-timezone>${escapeXml(timezone)}</c:calendar-timezone>\n`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
xml += ' </d:prop>\n';
|
|
230
|
+
xml += ' </d:set>\n';
|
|
231
|
+
xml += '</d:propertyupdate>';
|
|
232
|
+
|
|
233
|
+
return xml;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Generic filter function for events, contacts, or todos
|
|
238
|
+
* Applies multiple filters with case-insensitive substring matching
|
|
239
|
+
*
|
|
240
|
+
* @param {Array<Object>} items - Array of items to filter (events/contacts/todos)
|
|
241
|
+
* @param {Object} filters - Object with filter values
|
|
242
|
+
* @param {Object} extractors - Object mapping filter keys to regex extractors
|
|
243
|
+
* @returns {Array<Object>} Filtered items
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* // Filter events by summary and location
|
|
247
|
+
* const filtered = applyFilters(
|
|
248
|
+
* events,
|
|
249
|
+
* { summary_filter: 'meeting', location_filter: 'room' },
|
|
250
|
+
* {
|
|
251
|
+
* summary_filter: /SUMMARY:(.+)/,
|
|
252
|
+
* location_filter: /LOCATION:(.+)/
|
|
253
|
+
* }
|
|
254
|
+
* );
|
|
255
|
+
*/
|
|
256
|
+
export function applyFilters(items, filters, extractors) {
|
|
257
|
+
let filtered = items;
|
|
258
|
+
|
|
259
|
+
for (const [filterKey, filterValue] of Object.entries(filters)) {
|
|
260
|
+
if (!filterValue || !extractors[filterKey]) continue;
|
|
261
|
+
|
|
262
|
+
const regex = extractors[filterKey];
|
|
263
|
+
const searchLower = filterValue.toLowerCase();
|
|
264
|
+
|
|
265
|
+
filtered = filtered.filter(item => {
|
|
266
|
+
const match = item.data?.match(regex);
|
|
267
|
+
const value = match?.[1] || '';
|
|
268
|
+
return value.toLowerCase().includes(searchLower);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return filtered;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolves which calendars to search based on optional calendar_url parameter
|
|
277
|
+
* If calendar_url is provided, validates and returns single calendar in array
|
|
278
|
+
* Otherwise returns all calendars
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} client - The CalDAV client instance
|
|
281
|
+
* @param {string|undefined} calendarUrl - Optional specific calendar URL
|
|
282
|
+
* @returns {Promise<Array<Object>>} Array of calendars to search
|
|
283
|
+
* @throws {Error} If specific calendar not found
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* // Search specific calendar
|
|
287
|
+
* const calendars = await resolveCalendarsToSearch(client, 'https://...');
|
|
288
|
+
* // Returns: [specificCalendar]
|
|
289
|
+
*
|
|
290
|
+
* // Search all calendars
|
|
291
|
+
* const calendars = await resolveCalendarsToSearch(client, undefined);
|
|
292
|
+
* // Returns: [cal1, cal2, cal3, ...]
|
|
293
|
+
*/
|
|
294
|
+
export async function resolveCalendarsToSearch(client, calendarUrl) {
|
|
295
|
+
const calendars = await client.fetchCalendars();
|
|
296
|
+
|
|
297
|
+
// Search all calendars if no specific URL provided
|
|
298
|
+
if (!calendarUrl) {
|
|
299
|
+
return calendars;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Find specific calendar
|
|
303
|
+
const calendar = calendars.find(c => c.url === calendarUrl);
|
|
304
|
+
|
|
305
|
+
if (!calendar) {
|
|
306
|
+
const availableUrls = calendars.map(c => c.url).join('\n- ');
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Calendar not found: ${calendarUrl}\n\n` +
|
|
309
|
+
`Available calendar URLs:\n- ${availableUrls}\n\n` +
|
|
310
|
+
`Tip: Omit calendar_url to search across all calendars automatically.`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return [calendar];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Generates display name for single or multi-calendar searches
|
|
319
|
+
*
|
|
320
|
+
* @param {Array<Object>} calendars - Array of calendars that were searched
|
|
321
|
+
* @returns {string} Display name for formatter
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* getCalendarDisplayName([cal1]) // Returns: "Work Calendar"
|
|
325
|
+
* getCalendarDisplayName([cal1, cal2, cal3]) // Returns: "All Calendars (3)"
|
|
326
|
+
*/
|
|
327
|
+
export function getCalendarDisplayName(calendars) {
|
|
328
|
+
if (calendars.length === 1) {
|
|
329
|
+
return calendars[0].displayName || calendars[0].url;
|
|
330
|
+
}
|
|
331
|
+
return `All Calendars (${calendars.length})`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolves which address books to search based on optional URL
|
|
336
|
+
*
|
|
337
|
+
* @param {Object} client - The DAV client
|
|
338
|
+
* @param {string} [addressbookUrl] - Optional specific address book URL
|
|
339
|
+
* @returns {Promise<Array<Object>>} Array of address books to search
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* // Search specific address book
|
|
343
|
+
* const addressbooks = await resolveAddressBooksToSearch(client, 'http://example.com/addressbook');
|
|
344
|
+
* // Returns: [addressbook1]
|
|
345
|
+
*
|
|
346
|
+
* // Search all address books
|
|
347
|
+
* const addressbooks = await resolveAddressBooksToSearch(client, undefined);
|
|
348
|
+
* // Returns: [addressbook1, addressbook2, addressbook3, ...]
|
|
349
|
+
*/
|
|
350
|
+
export async function resolveAddressBooksToSearch(client, addressbookUrl) {
|
|
351
|
+
const addressbooks = await client.fetchAddressBooks();
|
|
352
|
+
|
|
353
|
+
// Search all address books if no specific URL provided
|
|
354
|
+
if (!addressbookUrl) {
|
|
355
|
+
return addressbooks;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Find specific address book
|
|
359
|
+
const addressbook = addressbooks.find(a => a.url === addressbookUrl);
|
|
360
|
+
|
|
361
|
+
if (!addressbook) {
|
|
362
|
+
const availableUrls = addressbooks.map(a => a.url).join('\n- ');
|
|
363
|
+
throw new Error(
|
|
364
|
+
`Address book not found: ${addressbookUrl}\n\n` +
|
|
365
|
+
`Available address book URLs:\n- ${availableUrls}\n\n` +
|
|
366
|
+
`Tip: Omit addressbook_url to search across all address books automatically.`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return [addressbook];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Generates display name for single or multi-addressbook searches
|
|
375
|
+
*
|
|
376
|
+
* @param {Array<Object>} addressbooks - Array of address books that were searched
|
|
377
|
+
* @returns {string} Display name for formatter
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* getAddressBookDisplayName([book1]) // Returns: "Personal Contacts"
|
|
381
|
+
* getAddressBookDisplayName([book1, book2, book3]) // Returns: "All Address Books (3)"
|
|
382
|
+
*/
|
|
383
|
+
export function getAddressBookDisplayName(addressbooks) {
|
|
384
|
+
if (addressbooks.length === 1) {
|
|
385
|
+
return addressbooks[0].displayName || addressbooks[0].url;
|
|
386
|
+
}
|
|
387
|
+
return `All Address Books (${addressbooks.length})`;
|
|
388
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validation schemas for all MCP tools
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Helper: DateTime string with optional timezone offset
|
|
8
|
+
// Accepts both "2026-03-02T09:00:00Z" and "2026-03-02T09:00:00"
|
|
9
|
+
const dateTimeWithOptionalOffset = z.union([
|
|
10
|
+
z.string().datetime({ offset: true }), // With timezone (Z or +00:00)
|
|
11
|
+
z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/, 'Invalid datetime format') // Without timezone
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Helper: Optional URL that gracefully handles LLM placeholder values
|
|
15
|
+
// Transforms common LLM-generated placeholders ("", "unknown", "default", etc.) to undefined
|
|
16
|
+
const optionalUrl = (message) =>
|
|
17
|
+
z.preprocess(
|
|
18
|
+
(val) => {
|
|
19
|
+
// Transform common LLM placeholder values to undefined
|
|
20
|
+
if (!val ||
|
|
21
|
+
val === '' ||
|
|
22
|
+
val === 'null' ||
|
|
23
|
+
val === 'undefined' ||
|
|
24
|
+
val === 'unknown' ||
|
|
25
|
+
val === 'default' ||
|
|
26
|
+
val === 'none' ||
|
|
27
|
+
val === 'N/A' ||
|
|
28
|
+
val === 'n/a') {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return val;
|
|
32
|
+
},
|
|
33
|
+
z.string().url(message).optional()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// CalDAV Schemas
|
|
37
|
+
export const listCalendarsSchema = z.object({});
|
|
38
|
+
|
|
39
|
+
export const listEventsSchema = z.object({
|
|
40
|
+
calendar_url: optionalUrl('Invalid calendar URL'),
|
|
41
|
+
time_range_start: dateTimeWithOptionalOffset.optional(),
|
|
42
|
+
time_range_end: dateTimeWithOptionalOffset.optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const createEventSchema = z.object({
|
|
46
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
47
|
+
summary: z.string().min(1, 'Summary is required').max(500),
|
|
48
|
+
start_date: dateTimeWithOptionalOffset,
|
|
49
|
+
end_date: dateTimeWithOptionalOffset,
|
|
50
|
+
description: z.string().max(5000).optional(),
|
|
51
|
+
location: z.string().max(500).optional(),
|
|
52
|
+
}).refine((data) => new Date(data.end_date) > new Date(data.start_date), {
|
|
53
|
+
message: 'End date must be after start date',
|
|
54
|
+
path: ['end_date'],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const updateEventSchema = z.object({
|
|
58
|
+
event_url: z.string().url('Invalid event URL'),
|
|
59
|
+
event_etag: z.string().min(1, 'ETag is required'),
|
|
60
|
+
updated_ical_data: z.string().min(1, 'iCal data is required'),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const deleteEventSchema = z.object({
|
|
64
|
+
event_url: z.string().url('Invalid event URL'),
|
|
65
|
+
event_etag: z.string().min(1, 'ETag is required'),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const calendarQuerySchema = z.object({
|
|
69
|
+
calendar_url: optionalUrl('Invalid calendar URL'),
|
|
70
|
+
time_range_start: dateTimeWithOptionalOffset.optional(),
|
|
71
|
+
time_range_end: dateTimeWithOptionalOffset.optional(),
|
|
72
|
+
summary_filter: z.string().optional(),
|
|
73
|
+
location_filter: z.string().optional(),
|
|
74
|
+
}).refine((data) => {
|
|
75
|
+
// Rule 1: If ANY time field used, BOTH must be present
|
|
76
|
+
if (data.time_range_start || data.time_range_end) {
|
|
77
|
+
return data.time_range_start && data.time_range_end;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Rule 2: At least ONE filter type must exist
|
|
81
|
+
return !!(data.calendar_url ||
|
|
82
|
+
data.summary_filter ||
|
|
83
|
+
data.location_filter);
|
|
84
|
+
}, {
|
|
85
|
+
message: "Provide: (time_range with BOTH dates) OR (text filter) OR (both)"
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const makeCalendarSchema = z.object({
|
|
89
|
+
display_name: z.string().min(1, 'Display name is required').max(200),
|
|
90
|
+
description: z.string().max(500).optional(),
|
|
91
|
+
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
|
92
|
+
timezone: z.string().optional(),
|
|
93
|
+
components: z.array(z.enum(['VEVENT', 'VTODO', 'VJOURNAL'])).optional(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const updateCalendarSchema = z.object({
|
|
97
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
98
|
+
display_name: z.string().min(1).max(200).optional(),
|
|
99
|
+
description: z.string().max(500).optional(),
|
|
100
|
+
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
|
101
|
+
timezone: z.string().optional(),
|
|
102
|
+
}).refine(data => {
|
|
103
|
+
// At least one field must be provided for update
|
|
104
|
+
return data.display_name || data.description || data.color || data.timezone;
|
|
105
|
+
}, {
|
|
106
|
+
message: 'At least one field (display_name, description, color, or timezone) must be provided for update',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const deleteCalendarSchema = z.object({
|
|
110
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const calendarMultiGetSchema = z.object({
|
|
114
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
115
|
+
event_urls: z.array(z.string().url('Invalid event URL')).min(1, 'At least one event URL required'),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// CardDAV Schemas
|
|
119
|
+
export const listAddressbooksSchema = z.object({});
|
|
120
|
+
|
|
121
|
+
export const listContactsSchema = z.object({
|
|
122
|
+
addressbook_url: z.string().url('Invalid addressbook URL'),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const createContactSchema = z.object({
|
|
126
|
+
addressbook_url: z.string().url('Invalid addressbook URL'),
|
|
127
|
+
full_name: z.string().min(1, 'Full name is required').max(200),
|
|
128
|
+
family_name: z.string().max(100).optional(),
|
|
129
|
+
given_name: z.string().max(100).optional(),
|
|
130
|
+
email: z.string().email('Invalid email format').optional(),
|
|
131
|
+
phone: z.string().max(50).optional(),
|
|
132
|
+
organization: z.string().max(200).optional(),
|
|
133
|
+
note: z.string().max(1000).optional(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const updateContactSchema = z.object({
|
|
137
|
+
vcard_url: z.string().url('Invalid vCard URL'),
|
|
138
|
+
vcard_etag: z.string().min(1, 'ETag is required'),
|
|
139
|
+
updated_vcard_data: z.string().min(1, 'vCard data is required'),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const deleteContactSchema = z.object({
|
|
143
|
+
vcard_url: z.string().url('Invalid vCard URL'),
|
|
144
|
+
vcard_etag: z.string().min(1, 'ETag is required'),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const addressBookQuerySchema = z.object({
|
|
148
|
+
addressbook_url: optionalUrl('Invalid addressbook URL'),
|
|
149
|
+
name_filter: z.string().optional(),
|
|
150
|
+
email_filter: z.string().optional(),
|
|
151
|
+
organization_filter: z.string().optional(),
|
|
152
|
+
}).refine((data) => {
|
|
153
|
+
// At least one filter required
|
|
154
|
+
return !!(data.name_filter ||
|
|
155
|
+
data.email_filter ||
|
|
156
|
+
data.organization_filter);
|
|
157
|
+
}, {
|
|
158
|
+
message: "At least one filter required: name_filter, email_filter, or organization_filter"
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const addressBookMultiGetSchema = z.object({
|
|
162
|
+
addressbook_url: z.string().url('Invalid addressbook URL'),
|
|
163
|
+
contact_urls: z.array(z.string().url('Invalid contact URL')).min(1, 'At least one contact URL required'),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// VTODO (Task) Schemas
|
|
167
|
+
export const listTodosSchema = z.object({
|
|
168
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const createTodoSchema = z.object({
|
|
172
|
+
calendar_url: z.string().url('Invalid calendar URL'),
|
|
173
|
+
summary: z.string().min(1, 'Summary is required').max(500),
|
|
174
|
+
description: z.string().max(5000).optional(),
|
|
175
|
+
due_date: z.string().optional(), // ISO 8601 with timezone
|
|
176
|
+
priority: z.number().int().min(0).max(9).optional(), // 0=undefined, 1=highest, 9=lowest
|
|
177
|
+
status: z.enum(['NEEDS-ACTION', 'IN-PROCESS', 'COMPLETED', 'CANCELLED']).optional(),
|
|
178
|
+
percent_complete: z.number().int().min(0).max(100).optional(),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const updateTodoSchema = z.object({
|
|
182
|
+
todo_url: z.string().url('Invalid todo URL'),
|
|
183
|
+
todo_etag: z.string().min(1, 'ETag is required'),
|
|
184
|
+
updated_ical_data: z.string().min(1, 'iCal data is required'),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const deleteTodoSchema = z.object({
|
|
188
|
+
todo_url: z.string().url('Invalid todo URL'),
|
|
189
|
+
todo_etag: z.string().min(1, 'ETag is required'),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export const todoQuerySchema = z.object({
|
|
193
|
+
calendar_url: optionalUrl('Invalid calendar URL'),
|
|
194
|
+
summary_filter: z.string().optional(),
|
|
195
|
+
status_filter: z.enum(['NEEDS-ACTION', 'IN-PROCESS', 'COMPLETED', 'CANCELLED']).optional(),
|
|
196
|
+
time_range_start: dateTimeWithOptionalOffset.optional(),
|
|
197
|
+
time_range_end: dateTimeWithOptionalOffset.optional(),
|
|
198
|
+
}).refine((data) => {
|
|
199
|
+
// Rule 1: If ANY time field used, BOTH must be present
|
|
200
|
+
if (data.time_range_start || data.time_range_end) {
|
|
201
|
+
return data.time_range_start && data.time_range_end;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Rule 2: At least ONE filter type must exist
|
|
205
|
+
return !!(data.calendar_url ||
|
|
206
|
+
data.summary_filter ||
|
|
207
|
+
data.status_filter);
|
|
208
|
+
}, {
|
|
209
|
+
message: "Provide: (time_range with BOTH dates) OR (text/status filter) OR (both)"
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export const todoMultiGetSchema = z.object({
|
|
213
|
+
todo_urls: z.array(z.string().url('Invalid todo URL')).min(1, 'At least one todo URL required'),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Validate input against a schema
|
|
218
|
+
*/
|
|
219
|
+
export function validateInput(schema, data) {
|
|
220
|
+
const result = schema.safeParse(data);
|
|
221
|
+
if (!result.success) {
|
|
222
|
+
const errors = result.error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ');
|
|
223
|
+
throw new Error(`Validation failed: ${errors}`);
|
|
224
|
+
}
|
|
225
|
+
return result.data;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Sanitize string for iCal/vCard format (escape special characters)
|
|
230
|
+
*/
|
|
231
|
+
export function sanitizeICalString(str) {
|
|
232
|
+
if (!str) return '';
|
|
233
|
+
return str
|
|
234
|
+
.replace(/\\/g, '\\\\') // Escape backslashes
|
|
235
|
+
.replace(/;/g, '\\;') // Escape semicolons
|
|
236
|
+
.replace(/,/g, '\\,') // Escape commas
|
|
237
|
+
.replace(/\n/g, '\\n'); // Escape newlines
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Sanitize vCard string
|
|
242
|
+
*/
|
|
243
|
+
export function sanitizeVCardString(str) {
|
|
244
|
+
return sanitizeICalString(str);
|
|
245
|
+
}
|