update caldav
This commit is contained in:
@@ -109,7 +109,7 @@ Before asking Claude to use Nextcloud:
|
|||||||
### "Calendar not found" or "Tasks not found"
|
### "Calendar not found" or "Tasks not found"
|
||||||
- Check that you have at least one calendar created
|
- Check that you have at least one calendar created
|
||||||
- Verify the Tasks app is installed and has a task list
|
- Verify the Tasks app is installed and has a task list
|
||||||
- See customization section in README for different calendar names
|
- Use `list_calendars` to discover exact calendar names/hrefs
|
||||||
|
|
||||||
### Claude doesn't show Nextcloud tools
|
### Claude doesn't show Nextcloud tools
|
||||||
- Verify Claude Desktop config file syntax (use a JSON validator)
|
- Verify Claude Desktop config file syntax (use a JSON validator)
|
||||||
@@ -120,10 +120,54 @@ Before asking Claude to use Nextcloud:
|
|||||||
## 🎯 Next Steps
|
## 🎯 Next Steps
|
||||||
|
|
||||||
Once working:
|
Once working:
|
||||||
1. Customize calendar/task list names in `src/index.ts` if needed
|
1. Use `list_calendars`, then call `get_calendar_events` with `calendar` or `calendars` when you want specific calendars
|
||||||
2. Add more tools as needed for your workflow
|
2. Add more tools as needed for your workflow
|
||||||
3. Check out the full README.md for advanced features
|
3. Check out the full README.md for advanced features
|
||||||
|
|
||||||
|
## 🔍 CalDAV Quick Debug (curl)
|
||||||
|
|
||||||
|
List calendars:
|
||||||
|
```bash
|
||||||
|
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||||
|
-X PROPFIND \
|
||||||
|
-H "Depth: 1" \
|
||||||
|
-H "Accept: application/xml" \
|
||||||
|
-H "Content-Type: application/xml; charset=utf-8" \
|
||||||
|
--data '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:resourcetype />
|
||||||
|
<c:supported-calendar-component-set />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>' \
|
||||||
|
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Query events:
|
||||||
|
```bash
|
||||||
|
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||||
|
-X REPORT \
|
||||||
|
-H "Depth: 1" \
|
||||||
|
-H "Accept: application/xml" \
|
||||||
|
-H "Content-Type: application/xml; charset=utf-8" \
|
||||||
|
--data '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag />
|
||||||
|
<c:calendar-data />
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VEVENT">
|
||||||
|
<c:time-range start="20260201T000000Z" end="20260301T000000Z"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>' \
|
||||||
|
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
|
||||||
|
```
|
||||||
|
|
||||||
## 📚 Example Use Cases
|
## 📚 Example Use Cases
|
||||||
|
|
||||||
**Task Management**:
|
**Task Management**:
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -15,6 +15,8 @@ A Model Context Protocol (MCP) server that integrates with Nextcloud to provide
|
|||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
- ✅ Get calendar events with date range filtering
|
- ✅ Get calendar events with date range filtering
|
||||||
|
- ✅ Query one, many, or all VEVENT calendars automatically
|
||||||
|
- ✅ Discover available calendars and supported components
|
||||||
- ✅ Create new calendar events with details and location
|
- ✅ Create new calendar events with details and location
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
@@ -125,9 +127,17 @@ Once connected, Claude can use these tools:
|
|||||||
- `update_task` - Update existing task (mark complete, change details)
|
- `update_task` - Update existing task (mark complete, change details)
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
- `get_calendar_events` - Get events in date range
|
- `list_calendars` - List available calendars (`name`, `href`, `components`)
|
||||||
|
- `get_calendar_events` - Get events in date range across selected calendars
|
||||||
- `create_calendar_event` - Create new event with details
|
- `create_calendar_event` - Create new event with details
|
||||||
|
|
||||||
|
`get_calendar_events` supports:
|
||||||
|
- `startDate` / `endDate` (YYYY-MM-DD)
|
||||||
|
- `calendar` (single calendar name or href)
|
||||||
|
- `calendars` (array of names/hrefs)
|
||||||
|
- `includeAllCalendars` (default `true`, queries all VEVENT calendars when no selectors are provided)
|
||||||
|
- `limit`
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- `get_notes` - List all notes
|
- `get_notes` - List all notes
|
||||||
- `create_note` - Create new note with markdown
|
- `create_note` - Create new note with markdown
|
||||||
@@ -158,9 +168,52 @@ Once the MCP server is connected, you can ask Claude:
|
|||||||
- Check that required apps (Tasks, Calendar, Notes) are installed
|
- Check that required apps (Tasks, Calendar, Notes) are installed
|
||||||
|
|
||||||
### CalDAV Issues
|
### CalDAV Issues
|
||||||
- Verify calendar/task list names match your Nextcloud setup
|
- Use `list_calendars` to discover calendar names/hrefs from your server
|
||||||
- Default calendar name is "personal" - adjust in code if needed
|
- Set `DEBUG_NEXTCLOUD_MCP=1` to log CalDAV requests and parsing details
|
||||||
- Default task list name is "tasks" - adjust in code if needed
|
- Default task list name is still `tasks` for task operations
|
||||||
|
|
||||||
|
### Debugging with curl
|
||||||
|
List calendars with PROPFIND:
|
||||||
|
```bash
|
||||||
|
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||||
|
-X PROPFIND \
|
||||||
|
-H "Depth: 1" \
|
||||||
|
-H "Accept: application/xml" \
|
||||||
|
-H "Content-Type: application/xml; charset=utf-8" \
|
||||||
|
--data '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:resourcetype />
|
||||||
|
<c:supported-calendar-component-set />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>' \
|
||||||
|
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Query events with REPORT:
|
||||||
|
```bash
|
||||||
|
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||||
|
-X REPORT \
|
||||||
|
-H "Depth: 1" \
|
||||||
|
-H "Accept: application/xml" \
|
||||||
|
-H "Content-Type: application/xml; charset=utf-8" \
|
||||||
|
--data '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag />
|
||||||
|
<c:calendar-data />
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VEVENT">
|
||||||
|
<c:time-range start="20260201T000000Z" end="20260301T000000Z"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>' \
|
||||||
|
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USERNAME/personal/"
|
||||||
|
```
|
||||||
|
|
||||||
### Email Issues
|
### Email Issues
|
||||||
- Ensure Nextcloud Mail app is installed and configured
|
- Ensure Nextcloud Mail app is installed and configured
|
||||||
@@ -187,15 +240,8 @@ Check the MCP server logs in Claude Desktop:
|
|||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
### Changing Calendar Names
|
### Calendar Selection
|
||||||
Edit `src/index.ts` and update the calendar paths:
|
Use `list_calendars` and pass `calendar` / `calendars` to `get_calendar_events` to target specific calendars by name or href.
|
||||||
```typescript
|
|
||||||
// Default personal calendar
|
|
||||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`;
|
|
||||||
|
|
||||||
// For a different calendar, change "personal" to your calendar name
|
|
||||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/work/`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changing Task List Names
|
### Changing Task List Names
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -882,6 +883,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
@@ -1188,6 +1190,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ical.js": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==",
|
||||||
|
"license": "MPL-2.0"
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -1775,6 +1783,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -13,13 +13,21 @@
|
|||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"test-connection": "node test-connection.js"
|
"test-connection": "node test-connection.js"
|
||||||
},
|
},
|
||||||
"keywords": ["mcp", "nextcloud", "calendar", "tasks", "notes", "email"],
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"nextcloud",
|
||||||
|
"calendar",
|
||||||
|
"tasks",
|
||||||
|
"notes",
|
||||||
|
"email"
|
||||||
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
524
src/caldav.ts
Normal file
524
src/caldav.ts
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import { format } from "date-fns";
|
||||||
|
import * as ICAL from "ical.js";
|
||||||
|
|
||||||
|
export interface CalendarInfo {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
components: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DebugLogger = (message: string) => void;
|
||||||
|
|
||||||
|
export function getCalDAVXmlHeaders(depth: string = "1"): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Accept: "application/xml",
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
Depth: depth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTasksReportBody(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag />
|
||||||
|
<c:calendar-data />
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VTODO" />
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarEventsReportBody(start: Date, end: Date): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag />
|
||||||
|
<c:calendar-data>
|
||||||
|
<c:expand start="${formatICalDateTimeUtc(start)}" end="${formatICalDateTimeUtc(end)}"/>
|
||||||
|
</c:calendar-data>
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VEVENT">
|
||||||
|
<c:time-range start="${formatICalDateTimeUtc(start)}" end="${formatICalDateTimeUtc(end)}"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarDiscoveryPropfindBody(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname />
|
||||||
|
<d:resourcetype />
|
||||||
|
<c:supported-calendar-component-set />
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTasksFromCalDAV(
|
||||||
|
xmlData: string,
|
||||||
|
status: string,
|
||||||
|
limit: number
|
||||||
|
): any[] {
|
||||||
|
const tasks: any[] = [];
|
||||||
|
for (const todoData of extractCalendarDataBlocks(xmlData)) {
|
||||||
|
if (tasks.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = parseVTODO(todoData);
|
||||||
|
if (!task) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "all") {
|
||||||
|
tasks.push(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status === "completed" && task.status === "COMPLETED") {
|
||||||
|
tasks.push(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status === "open" && task.status !== "COMPLETED") {
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEventsFromCalDAV(
|
||||||
|
xmlData: string,
|
||||||
|
calendarHref: string,
|
||||||
|
debugLog?: DebugLogger
|
||||||
|
): any[] {
|
||||||
|
const events: any[] = [];
|
||||||
|
const blocks = extractCalendarDataBlocks(xmlData);
|
||||||
|
debugLog?.(`Extracted ${blocks.length} calendar-data blocks from ${calendarHref}`);
|
||||||
|
|
||||||
|
for (const calendarData of blocks) {
|
||||||
|
try {
|
||||||
|
const jcalData = ICAL.parse(calendarData);
|
||||||
|
const comp = new ICAL.Component(jcalData);
|
||||||
|
const vevents = comp.getAllSubcomponents("vevent");
|
||||||
|
|
||||||
|
for (const vevent of vevents) {
|
||||||
|
const event: any = {};
|
||||||
|
event.uid = vevent.getFirstPropertyValue("uid");
|
||||||
|
|
||||||
|
if (vevent.hasProperty("summary")) {
|
||||||
|
event.summary = vevent.getFirstPropertyValue("summary");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("description")) {
|
||||||
|
event.description = vevent.getFirstPropertyValue("description");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("location")) {
|
||||||
|
event.location = vevent.getFirstPropertyValue("location");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("status")) {
|
||||||
|
event.status = vevent.getFirstPropertyValue("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vevent.hasProperty("dtstart")) {
|
||||||
|
const startProp = vevent.getFirstProperty("dtstart");
|
||||||
|
event.startRaw = startProp.getFirstValue().toString();
|
||||||
|
event.start = event.startRaw;
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("dtend")) {
|
||||||
|
const endProp = vevent.getFirstProperty("dtend");
|
||||||
|
event.end = endProp.getFirstValue().toString();
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("rrule")) {
|
||||||
|
const rrule = vevent.getFirstPropertyValue("rrule");
|
||||||
|
event.rrule = rrule.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const alarms = vevent.getAllSubcomponents("valarm");
|
||||||
|
if (alarms && alarms.length > 0) {
|
||||||
|
event.alarms = alarms.map((alarm: any) => ({
|
||||||
|
action: alarm.getFirstPropertyValue("action"),
|
||||||
|
trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null,
|
||||||
|
description: alarm.getFirstPropertyValue("description")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.uid) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugLog?.(`Failed to parse iCal block: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog?.(`Parsed ${events.length} events from ${calendarHref}`);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCalendarsFromPROPFIND(
|
||||||
|
xmlData: string,
|
||||||
|
calendarsRoot: string
|
||||||
|
): CalendarInfo[] {
|
||||||
|
const calendarsByHref = new Map<string, CalendarInfo>();
|
||||||
|
const responseMatches = xmlData.matchAll(
|
||||||
|
/<(?:\w+:)?response\b[\s\S]*?<\/(?:\w+:)?response>/g
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const match of responseMatches) {
|
||||||
|
const responseBlock = match[0];
|
||||||
|
const hrefMatch = responseBlock.match(
|
||||||
|
/<(?:\w+:)?href[^>]*>([\s\S]*?)<\/(?:\w+:)?href>/
|
||||||
|
);
|
||||||
|
if (!hrefMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHref = decodeXmlText(hrefMatch[1].trim());
|
||||||
|
const href = normalizeCalendarHref(rawHref);
|
||||||
|
if (!href.startsWith(calendarsRoot) || href === calendarsRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCalendar = /<(?:\w+:)?calendar(?:\s*\/>|>)/i.test(responseBlock);
|
||||||
|
if (!isCalendar) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentMatches = responseBlock.matchAll(
|
||||||
|
/<(?:\w+:)?comp\b[^>]*\bname="([^"]+)"/gi
|
||||||
|
);
|
||||||
|
const detectedComponents = Array.from(
|
||||||
|
new Set(
|
||||||
|
Array.from(componentMatches, (componentMatch) =>
|
||||||
|
componentMatch[1].toUpperCase()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const components =
|
||||||
|
detectedComponents.length > 0 ? detectedComponents : ["VEVENT", "VTODO"];
|
||||||
|
|
||||||
|
const displayNameMatch = responseBlock.match(
|
||||||
|
/<(?:\w+:)?displayname[^>]*>([\s\S]*?)<\/(?:\w+:)?displayname>/
|
||||||
|
);
|
||||||
|
const displayName = displayNameMatch
|
||||||
|
? decodeXmlText(displayNameMatch[1].trim())
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const fallbackName = href
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop();
|
||||||
|
const name = displayName || fallbackName || "Unnamed";
|
||||||
|
|
||||||
|
calendarsByHref.set(href, { name, href, components });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(calendarsByHref.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCalendarSelectors(
|
||||||
|
selectors: string[],
|
||||||
|
calendars: CalendarInfo[]
|
||||||
|
): CalendarInfo[] {
|
||||||
|
const selected = new Map<string, CalendarInfo>();
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const normalizedSelectorHref = normalizeCalendarHref(selector);
|
||||||
|
const byHref = calendars.find(
|
||||||
|
(calendar) => normalizeCalendarHref(calendar.href) === normalizedSelectorHref
|
||||||
|
);
|
||||||
|
const byName = calendars.find(
|
||||||
|
(calendar) => calendar.name.toLowerCase() === selector.toLowerCase()
|
||||||
|
);
|
||||||
|
const resolved = byHref || byName;
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(`Calendar not found: ${selector}`);
|
||||||
|
}
|
||||||
|
selected.set(resolved.href, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(selected.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeEvents(events: any[]): any[] {
|
||||||
|
const unique = new Map<string, any>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const uid = typeof event.uid === "string" ? event.uid : "";
|
||||||
|
if (!uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dtstart =
|
||||||
|
typeof event.startRaw === "string" && event.startRaw.trim()
|
||||||
|
? event.startRaw.trim()
|
||||||
|
: "";
|
||||||
|
const key = dtstart ? `${uid}|${dtstart}` : uid;
|
||||||
|
if (!unique.has(key)) {
|
||||||
|
unique.set(key, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(unique.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventSortTimestamp(event: any): number {
|
||||||
|
const raw = typeof event.startRaw === "string" ? event.startRaw : "";
|
||||||
|
if (!raw) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
const parsed = parseICalToDate(raw);
|
||||||
|
return parsed ? parsed.getTime() : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripEventInternalFields(event: any): any {
|
||||||
|
const { startRaw, ...rest } = event;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseISODateOnlyLocalMidnight(
|
||||||
|
dateStr: string,
|
||||||
|
fieldName: string
|
||||||
|
): Date {
|
||||||
|
const match = dateStr.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`${fieldName} must be in YYYY-MM-DD or YYYY/MM/DD format`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
let day = Number(match[3]);
|
||||||
|
|
||||||
|
if (month < 1 || month > 12) {
|
||||||
|
throw new Error(`${fieldName} month must be between 01 and 12`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDay = new Date(year, month, 0).getDate();
|
||||||
|
if (day < 1) {
|
||||||
|
day = 1;
|
||||||
|
} else if (day > maxDay) {
|
||||||
|
day = maxDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
return localDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatICalDateTimeUtc(date: Date): string {
|
||||||
|
const utcShifted = new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000);
|
||||||
|
return format(utcShifted, "yyyyMMdd'T'HHmmss'Z'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatICalDate(date: Date): string {
|
||||||
|
return format(date, "yyyyMMdd");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseICalDate(icalDate: string): string {
|
||||||
|
if (icalDate.includes("T")) {
|
||||||
|
const year = icalDate.substring(0, 4);
|
||||||
|
const month = icalDate.substring(4, 6);
|
||||||
|
const day = icalDate.substring(6, 8);
|
||||||
|
const hour = icalDate.substring(9, 11);
|
||||||
|
const minute = icalDate.substring(11, 13);
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = icalDate.substring(0, 4);
|
||||||
|
const month = icalDate.substring(4, 6);
|
||||||
|
const day = icalDate.substring(6, 8);
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCalendarDataBlocks(xmlData: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
xmlData.matchAll(
|
||||||
|
/<(?:\w+:)?calendar-data[^>]*>([\s\S]*?)<\/(?:\w+:)?calendar-data>/g
|
||||||
|
),
|
||||||
|
(match) => decodeXmlText(match[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVTODO(todoData: string): any | null {
|
||||||
|
try {
|
||||||
|
const jcalData = ICAL.parse(todoData);
|
||||||
|
const comp = new ICAL.Component(jcalData);
|
||||||
|
const vtodo = comp.getFirstSubcomponent("vtodo");
|
||||||
|
if (!vtodo) return null;
|
||||||
|
|
||||||
|
const task: any = {};
|
||||||
|
task.uid = vtodo.getFirstPropertyValue("uid");
|
||||||
|
|
||||||
|
if (vtodo.hasProperty("summary")) {
|
||||||
|
task.summary = vtodo.getFirstPropertyValue("summary");
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("status")) {
|
||||||
|
task.status = vtodo.getFirstPropertyValue("status");
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("description")) {
|
||||||
|
task.description = vtodo.getFirstPropertyValue("description");
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("percent-complete")) {
|
||||||
|
task.percentComplete = vtodo.getFirstPropertyValue("percent-complete");
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("priority")) {
|
||||||
|
task.priority = vtodo.getFirstPropertyValue("priority");
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("due")) {
|
||||||
|
const dueProp = vtodo.getFirstProperty("due");
|
||||||
|
if (dueProp) {
|
||||||
|
task.due = dueProp.getFirstValue().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("created")) {
|
||||||
|
const createdProp = vtodo.getFirstProperty("created");
|
||||||
|
if (createdProp) {
|
||||||
|
task.created = createdProp.getFirstValue().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vtodo.hasProperty("last-modified")) {
|
||||||
|
const lmProp = vtodo.getFirstProperty("last-modified");
|
||||||
|
if (lmProp) {
|
||||||
|
task.lastModified = lmProp.getFirstValue().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.uid ? task : null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVEVENT(eventData: string): any | null {
|
||||||
|
try {
|
||||||
|
const jcalData = ICAL.parse(eventData);
|
||||||
|
const comp = new ICAL.Component(jcalData);
|
||||||
|
const vevents = comp.getAllSubcomponents("vevent");
|
||||||
|
|
||||||
|
// We expect individual events here, handled iteration in the caller if needed
|
||||||
|
// However, if the calendarData block has multiple events, ICAL parsing the whole
|
||||||
|
// object will find them. We'll return the first one here that has a UID.
|
||||||
|
const vevent = comp.getFirstSubcomponent("vevent");
|
||||||
|
if (!vevent) return null;
|
||||||
|
|
||||||
|
const event: any = {};
|
||||||
|
event.uid = vevent.getFirstPropertyValue("uid");
|
||||||
|
|
||||||
|
if (vevent.hasProperty("summary")) {
|
||||||
|
event.summary = vevent.getFirstPropertyValue("summary");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("description")) {
|
||||||
|
event.description = vevent.getFirstPropertyValue("description");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("location")) {
|
||||||
|
event.location = vevent.getFirstPropertyValue("location");
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("status")) {
|
||||||
|
event.status = vevent.getFirstPropertyValue("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vevent.hasProperty("dtstart")) {
|
||||||
|
const startProp = vevent.getFirstProperty("dtstart");
|
||||||
|
event.startRaw = startProp.getFirstValue().toString();
|
||||||
|
event.start = event.startRaw;
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("dtend")) {
|
||||||
|
const endProp = vevent.getFirstProperty("dtend");
|
||||||
|
event.end = endProp.getFirstValue().toString();
|
||||||
|
}
|
||||||
|
if (vevent.hasProperty("rrule")) {
|
||||||
|
const rrule = vevent.getFirstPropertyValue("rrule");
|
||||||
|
event.rrule = rrule.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const alarms = vevent.getAllSubcomponents("valarm");
|
||||||
|
if (alarms && alarms.length > 0) {
|
||||||
|
event.alarms = alarms.map((alarm: any) => ({
|
||||||
|
action: alarm.getFirstPropertyValue("action"),
|
||||||
|
trigger: alarm.getFirstPropertyValue("trigger") ? alarm.getFirstProperty("trigger").getFirstValue().toString() : null,
|
||||||
|
description: alarm.getFirstPropertyValue("description")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.uid ? event : null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVEventBlocks(calendarData: string): string[] {
|
||||||
|
// ICAL.js does not need string splitting, it parses the whole VCALENDAR.
|
||||||
|
// We'll leave this to split generic strings if needed by the old logic,
|
||||||
|
// but better yet, let's just parse the full calendarData string directly in the caller.
|
||||||
|
return Array.from(
|
||||||
|
calendarData.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g),
|
||||||
|
(match) => `BEGIN:VCALENDAR\nVERSION:2.0\n${match[0]}\nEND:VCALENDAR`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unfoldICalLines(data: string): string[] {
|
||||||
|
// Normalize CR artifacts and unfold folded iCalendar lines.
|
||||||
|
const normalized = data.replace(/\r/g, "").replace(/\n[ \t]/g, "");
|
||||||
|
return normalized.split(/\n/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCalendarHref(href: string): string {
|
||||||
|
let normalized = href.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(normalized)) {
|
||||||
|
try {
|
||||||
|
normalized = new URL(normalized).pathname;
|
||||||
|
} catch {
|
||||||
|
// Keep original if URL parsing fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.startsWith("/")) {
|
||||||
|
normalized = `/${normalized}`;
|
||||||
|
}
|
||||||
|
if (!normalized.endsWith("/")) {
|
||||||
|
normalized = `${normalized}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) =>
|
||||||
|
String.fromCodePoint(parseInt(hex, 16))
|
||||||
|
)
|
||||||
|
.replace(/&#([0-9]+);/g, (_m, dec) =>
|
||||||
|
String.fromCodePoint(parseInt(dec, 10))
|
||||||
|
)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, "\"")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseICalToDate(icalDate: string): Date | null {
|
||||||
|
const match = icalDate.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]) - 1;
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const hour = match[4] ? Number(match[4]) : 0;
|
||||||
|
const minute = match[5] ? Number(match[5]) : 0;
|
||||||
|
const second = match[6] ? Number(match[6]) : 0;
|
||||||
|
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
||||||
|
}
|
||||||
535
src/index.ts
535
src/index.ts
@@ -8,7 +8,24 @@ import {
|
|||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { parseISO, format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
CalendarInfo,
|
||||||
|
buildCalendarDiscoveryPropfindBody,
|
||||||
|
buildCalendarEventsReportBody,
|
||||||
|
buildTasksReportBody,
|
||||||
|
dedupeEvents,
|
||||||
|
formatICalDate,
|
||||||
|
formatICalDateTimeUtc,
|
||||||
|
getCalDAVXmlHeaders,
|
||||||
|
getEventSortTimestamp,
|
||||||
|
parseCalendarsFromPROPFIND,
|
||||||
|
parseEventsFromCalDAV,
|
||||||
|
parseISODateOnlyLocalMidnight,
|
||||||
|
parseTasksFromCalDAV,
|
||||||
|
resolveCalendarSelectors,
|
||||||
|
stripEventInternalFields,
|
||||||
|
} from "./caldav.js";
|
||||||
|
|
||||||
interface NextcloudConfig {
|
interface NextcloudConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -20,9 +37,11 @@ class NextcloudMCPServer {
|
|||||||
private server: Server;
|
private server: Server;
|
||||||
private axiosInstance: AxiosInstance;
|
private axiosInstance: AxiosInstance;
|
||||||
private config: NextcloudConfig;
|
private config: NextcloudConfig;
|
||||||
|
private debugEnabled: boolean;
|
||||||
|
|
||||||
constructor(config: NextcloudConfig) {
|
constructor(config: NextcloudConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.debugEnabled = process.env.DEBUG_NEXTCLOUD_MCP === "1";
|
||||||
this.server = new Server(
|
this.server = new Server(
|
||||||
{
|
{
|
||||||
name: "nextcloud-mcp-server",
|
name: "nextcloud-mcp-server",
|
||||||
@@ -85,6 +104,8 @@ class NextcloudMCPServer {
|
|||||||
return await this.updateTask(args as any);
|
return await this.updateTask(args as any);
|
||||||
case "get_calendar_events":
|
case "get_calendar_events":
|
||||||
return await this.getCalendarEvents(args as any);
|
return await this.getCalendarEvents(args as any);
|
||||||
|
case "list_calendars":
|
||||||
|
return await this.listCalendars();
|
||||||
case "create_calendar_event":
|
case "create_calendar_event":
|
||||||
return await this.createCalendarEvent(args as any);
|
return await this.createCalendarEvent(args as any);
|
||||||
case "get_notes":
|
case "get_notes":
|
||||||
@@ -207,6 +228,23 @@ class NextcloudMCPServer {
|
|||||||
description:
|
description:
|
||||||
"End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.",
|
"End date in ISO format (YYYY-MM-DD). Defaults to 30 days from start.",
|
||||||
},
|
},
|
||||||
|
calendar: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Calendar selector by display name or href path (optional)",
|
||||||
|
},
|
||||||
|
calendars: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description:
|
||||||
|
"List of calendar selectors by display name or href path (optional)",
|
||||||
|
},
|
||||||
|
includeAllCalendars: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"When true and no calendar selectors are provided, query all VEVENT calendars",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: "number",
|
type: "number",
|
||||||
description: "Maximum number of events to return",
|
description: "Maximum number of events to return",
|
||||||
@@ -215,6 +253,15 @@ class NextcloudMCPServer {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "list_calendars",
|
||||||
|
description:
|
||||||
|
"List available Nextcloud CalDAV calendars with href and supported components",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "create_calendar_event",
|
name: "create_calendar_event",
|
||||||
description: "Create a new calendar event in Nextcloud",
|
description: "Create a new calendar event in Nextcloud",
|
||||||
@@ -229,20 +276,57 @@ class NextcloudMCPServer {
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "Event description (optional)",
|
description: "Event description (optional)",
|
||||||
},
|
},
|
||||||
|
allDay: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Create an all-day event. Use startDate/endDate when true.",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
startDateTime: {
|
startDateTime: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss)",
|
description:
|
||||||
|
"Start date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
|
||||||
},
|
},
|
||||||
endDateTime: {
|
endDateTime: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "End date/time in ISO format (YYYY-MM-DDTHH:mm:ss)",
|
description:
|
||||||
|
"End date/time in ISO format (YYYY-MM-DDTHH:mm:ss). Required for timed events.",
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"All-day start date in YYYY-MM-DD (or YYYY/MM/DD). Required when allDay=true.",
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"All-day inclusive end date in YYYY-MM-DD (or YYYY/MM/DD). Optional; defaults to startDate.",
|
||||||
|
},
|
||||||
|
reminderMinutesBefore: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"Reminder trigger relative to event start, in minutes before start (optional).",
|
||||||
|
},
|
||||||
|
reminderDateTime: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Absolute reminder timestamp in ISO format (optional, mutually exclusive with reminderMinutesBefore).",
|
||||||
|
},
|
||||||
|
reminderDescription: {
|
||||||
|
type: "string",
|
||||||
|
description: "Reminder text shown to the user (optional).",
|
||||||
|
},
|
||||||
|
reminderAction: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Alarm action type (optional, defaults to DISPLAY).",
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Event location (optional)",
|
description: "Event location (optional)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["summary", "startDateTime", "endDateTime"],
|
required: ["summary"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Notes tools
|
// Notes tools
|
||||||
@@ -329,30 +413,16 @@ class NextcloudMCPServer {
|
|||||||
// CalDAV REPORT request to get tasks
|
// CalDAV REPORT request to get tasks
|
||||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`;
|
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/tasks/`;
|
||||||
|
|
||||||
const requestBody = `<?xml version="1.0" encoding="UTF-8"?>
|
const requestBody = buildTasksReportBody();
|
||||||
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
|
||||||
<d:prop>
|
|
||||||
<d:getetag />
|
|
||||||
<c:calendar-data />
|
|
||||||
</d:prop>
|
|
||||||
<c:filter>
|
|
||||||
<c:comp-filter name="VCALENDAR">
|
|
||||||
<c:comp-filter name="VTODO" />
|
|
||||||
</c:comp-filter>
|
|
||||||
</c:filter>
|
|
||||||
</c:calendar-query>`;
|
|
||||||
|
|
||||||
const response = await this.axiosInstance.request({
|
const response = await this.axiosInstance.request({
|
||||||
method: "REPORT",
|
method: "REPORT",
|
||||||
url: caldavPath,
|
url: caldavPath,
|
||||||
data: requestBody,
|
data: requestBody,
|
||||||
headers: {
|
headers: getCalDAVXmlHeaders("1"),
|
||||||
"Content-Type": "application/xml",
|
|
||||||
Depth: "1",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = this.parseTasksFromCalDAV(response.data, status, limit);
|
const tasks = parseTasksFromCalDAV(response.data, status, limit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -367,72 +437,6 @@ class NextcloudMCPServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTasksFromCalDAV(
|
|
||||||
xmlData: string,
|
|
||||||
status: string,
|
|
||||||
limit: number
|
|
||||||
): any[] {
|
|
||||||
const tasks: any[] = [];
|
|
||||||
|
|
||||||
// Basic XML parsing for VTODO components
|
|
||||||
const todoMatches = xmlData.matchAll(
|
|
||||||
/<c:calendar-data[^>]*>([\s\S]*?)<\/c:calendar-data>/g
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const match of todoMatches) {
|
|
||||||
if (tasks.length >= limit) break;
|
|
||||||
|
|
||||||
const todoData = match[1];
|
|
||||||
const task = this.parseVTODO(todoData);
|
|
||||||
|
|
||||||
if (task) {
|
|
||||||
if (status === "all") {
|
|
||||||
tasks.push(task);
|
|
||||||
} else if (
|
|
||||||
status === "completed" &&
|
|
||||||
task.status === "COMPLETED"
|
|
||||||
) {
|
|
||||||
tasks.push(task);
|
|
||||||
} else if (
|
|
||||||
status === "open" &&
|
|
||||||
task.status !== "COMPLETED"
|
|
||||||
) {
|
|
||||||
tasks.push(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseVTODO(todoData: string): any | null {
|
|
||||||
const lines = todoData.split(/\r?\n/);
|
|
||||||
const task: any = {};
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("UID:")) {
|
|
||||||
task.uid = line.substring(4).trim();
|
|
||||||
} else if (line.startsWith("SUMMARY:")) {
|
|
||||||
task.summary = line.substring(8).trim();
|
|
||||||
} else if (line.startsWith("STATUS:")) {
|
|
||||||
task.status = line.substring(7).trim();
|
|
||||||
} else if (line.startsWith("PERCENT-COMPLETE:")) {
|
|
||||||
task.percentComplete = parseInt(line.substring(17).trim());
|
|
||||||
} else if (line.startsWith("DUE")) {
|
|
||||||
const dueMatch = line.match(/DUE[^:]*:(\d{8}T?\d{6}Z?)/);
|
|
||||||
if (dueMatch) {
|
|
||||||
task.due = this.parseICalDate(dueMatch[1]);
|
|
||||||
}
|
|
||||||
} else if (line.startsWith("PRIORITY:")) {
|
|
||||||
task.priority = parseInt(line.substring(9).trim());
|
|
||||||
} else if (line.startsWith("DESCRIPTION:")) {
|
|
||||||
task.description = line.substring(12).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return task.uid ? task : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createTask(args: any) {
|
private async createTask(args: any) {
|
||||||
const { summary, description, due, priority } = args;
|
const { summary, description, due, priority } = args;
|
||||||
const uid = this.generateUID();
|
const uid = this.generateUID();
|
||||||
@@ -444,13 +448,13 @@ BEGIN:VTODO
|
|||||||
UID:${uid}
|
UID:${uid}
|
||||||
SUMMARY:${summary}
|
SUMMARY:${summary}
|
||||||
STATUS:NEEDS-ACTION
|
STATUS:NEEDS-ACTION
|
||||||
CREATED:${this.formatICalDateTime(new Date())}`;
|
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
vtodo += `\nDESCRIPTION:${description}`;
|
vtodo += `\nDESCRIPTION:${description}`;
|
||||||
}
|
}
|
||||||
if (due) {
|
if (due) {
|
||||||
vtodo += `\nDUE:${this.formatICalDate(new Date(due))}`;
|
vtodo += `\nDUE:${formatICalDate(new Date(due))}`;
|
||||||
}
|
}
|
||||||
if (priority) {
|
if (priority) {
|
||||||
vtodo += `\nPRIORITY:${priority}`;
|
vtodo += `\nPRIORITY:${priority}`;
|
||||||
@@ -515,7 +519,7 @@ END:VCALENDAR`;
|
|||||||
// Update LAST-MODIFIED
|
// Update LAST-MODIFIED
|
||||||
vtodo = vtodo.replace(
|
vtodo = vtodo.replace(
|
||||||
/LAST-MODIFIED:.*/,
|
/LAST-MODIFIED:.*/,
|
||||||
`LAST-MODIFIED:${this.formatICalDateTime(new Date())}`
|
`LAST-MODIFIED:${formatICalDateTimeUtc(new Date())}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.axiosInstance.put(caldavPath, vtodo, {
|
await this.axiosInstance.put(caldavPath, vtodo, {
|
||||||
@@ -538,6 +542,18 @@ END:VCALENDAR`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== CALENDAR METHODS ==========
|
// ========== CALENDAR METHODS ==========
|
||||||
|
private async listCalendars() {
|
||||||
|
const calendars = await this.discoverCalendars();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(calendars, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async getCalendarEvents(args: any) {
|
private async getCalendarEvents(args: any) {
|
||||||
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
|
const startDate = args.startDate || format(new Date(), "yyyy-MM-dd");
|
||||||
const endDate =
|
const endDate =
|
||||||
@@ -547,38 +563,107 @@ END:VCALENDAR`;
|
|||||||
"yyyy-MM-dd"
|
"yyyy-MM-dd"
|
||||||
);
|
);
|
||||||
const limit = args.limit || 50;
|
const limit = args.limit || 50;
|
||||||
|
const includeAllCalendars = args.includeAllCalendars !== false;
|
||||||
|
const selectorList: string[] = [];
|
||||||
|
if (typeof args.calendar === "string" && args.calendar.trim()) {
|
||||||
|
selectorList.push(args.calendar.trim());
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.calendars)) {
|
||||||
|
for (const selector of args.calendars) {
|
||||||
|
if (typeof selector === "string" && selector.trim()) {
|
||||||
|
selectorList.push(selector.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocal = parseISODateOnlyLocalMidnight(startDate, "startDate");
|
||||||
|
const endLocal = parseISODateOnlyLocalMidnight(endDate, "endDate");
|
||||||
|
|
||||||
|
if (endLocal < startLocal) {
|
||||||
|
throw new Error("endDate must be on or after startDate");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caldavPath = `/remote.php/dav/calendars/${this.config.username}/personal/`;
|
const calendars = await this.discoverCalendars();
|
||||||
|
const veventCalendars = calendars.filter((calendar) =>
|
||||||
|
calendar.components.includes("VEVENT")
|
||||||
|
);
|
||||||
|
|
||||||
const requestBody = `<?xml version="1.0" encoding="UTF-8"?>
|
if (veventCalendars.length === 0) {
|
||||||
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
return {
|
||||||
<d:prop>
|
content: [
|
||||||
<d:getetag />
|
{
|
||||||
<c:calendar-data />
|
type: "text",
|
||||||
</d:prop>
|
text: JSON.stringify([], null, 2),
|
||||||
<c:filter>
|
},
|
||||||
<c:comp-filter name="VCALENDAR">
|
],
|
||||||
<c:comp-filter name="VEVENT">
|
};
|
||||||
<c:time-range start="${this.formatICalDate(
|
}
|
||||||
new Date(startDate)
|
|
||||||
)}" end="${this.formatICalDate(new Date(endDate))}"/>
|
|
||||||
</c:comp-filter>
|
|
||||||
</c:comp-filter>
|
|
||||||
</c:filter>
|
|
||||||
</c:calendar-query>`;
|
|
||||||
|
|
||||||
const response = await this.axiosInstance.request({
|
let targetCalendars: CalendarInfo[] = [];
|
||||||
method: "REPORT",
|
if (selectorList.length > 0) {
|
||||||
url: caldavPath,
|
targetCalendars = resolveCalendarSelectors(selectorList, calendars);
|
||||||
data: requestBody,
|
} else if (includeAllCalendars) {
|
||||||
headers: {
|
targetCalendars = veventCalendars;
|
||||||
"Content-Type": "application/xml",
|
} else {
|
||||||
Depth: "1",
|
const personalCalendar = veventCalendars.find(
|
||||||
},
|
(calendar) => calendar.name.toLowerCase() === "personal"
|
||||||
});
|
);
|
||||||
|
targetCalendars = personalCalendar ? [personalCalendar] : [veventCalendars[0]];
|
||||||
|
}
|
||||||
|
|
||||||
const events = this.parseEventsFromCalDAV(response.data, limit);
|
const requestBody = buildCalendarEventsReportBody(startLocal, endLocal);
|
||||||
|
|
||||||
|
const mergedEvents: any[] = [];
|
||||||
|
const requestErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const calendar of targetCalendars) {
|
||||||
|
try {
|
||||||
|
this.debugLog(`Querying calendar href: ${calendar.href}`);
|
||||||
|
this.debugLog(`REPORT body for ${calendar.href}:\n${requestBody}`);
|
||||||
|
|
||||||
|
const response = await this.axiosInstance.request({
|
||||||
|
method: "REPORT",
|
||||||
|
url: calendar.href,
|
||||||
|
data: requestBody,
|
||||||
|
headers: getCalDAVXmlHeaders("1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = String(response.data ?? "");
|
||||||
|
this.debugLog(
|
||||||
|
`REPORT response for ${calendar.href}: status=${response.status} body-preview=${responseBody.slice(
|
||||||
|
0,
|
||||||
|
500
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = parseEventsFromCalDAV(
|
||||||
|
responseBody,
|
||||||
|
calendar.href,
|
||||||
|
(message) => this.debugLog(message)
|
||||||
|
).map((event) => ({
|
||||||
|
...event,
|
||||||
|
calendarName: calendar.name,
|
||||||
|
calendarHref: calendar.href,
|
||||||
|
}));
|
||||||
|
mergedEvents.push(...events);
|
||||||
|
} catch (error: any) {
|
||||||
|
requestErrors.push(`${calendar.href}: ${error.message}`);
|
||||||
|
this.debugLog(`Calendar query failed for ${calendar.href}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedEvents.length === 0 && requestErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch calendar events from selected calendars: ${requestErrors.join(
|
||||||
|
"; "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = dedupeEvents(mergedEvents);
|
||||||
|
deduped.sort((a, b) => getEventSortTimestamp(a) - getEventSortTimestamp(b));
|
||||||
|
const events = deduped.slice(0, limit).map((event) => stripEventInternalFields(event));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -593,76 +678,123 @@ END:VCALENDAR`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseEventsFromCalDAV(xmlData: string, limit: number): any[] {
|
|
||||||
const events: any[] = [];
|
|
||||||
|
|
||||||
const eventMatches = xmlData.matchAll(
|
|
||||||
/<c:calendar-data[^>]*>([\s\S]*?)<\/c:calendar-data>/g
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const match of eventMatches) {
|
|
||||||
if (events.length >= limit) break;
|
|
||||||
|
|
||||||
const eventData = match[1];
|
|
||||||
const event = this.parseVEVENT(eventData);
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseVEVENT(eventData: string): any | null {
|
|
||||||
const lines = eventData.split(/\r?\n/);
|
|
||||||
const event: any = {};
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("UID:")) {
|
|
||||||
event.uid = line.substring(4).trim();
|
|
||||||
} else if (line.startsWith("SUMMARY:")) {
|
|
||||||
event.summary = line.substring(8).trim();
|
|
||||||
} else if (line.startsWith("DESCRIPTION:")) {
|
|
||||||
event.description = line.substring(12).trim();
|
|
||||||
} else if (line.startsWith("LOCATION:")) {
|
|
||||||
event.location = line.substring(9).trim();
|
|
||||||
} else if (line.startsWith("DTSTART")) {
|
|
||||||
const startMatch = line.match(/DTSTART[^:]*:(\d{8}T?\d{6}Z?)/);
|
|
||||||
if (startMatch) {
|
|
||||||
event.start = this.parseICalDate(startMatch[1]);
|
|
||||||
}
|
|
||||||
} else if (line.startsWith("DTEND")) {
|
|
||||||
const endMatch = line.match(/DTEND[^:]*:(\d{8}T?\d{6}Z?)/);
|
|
||||||
if (endMatch) {
|
|
||||||
event.end = this.parseICalDate(endMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.uid ? event : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createCalendarEvent(args: any) {
|
private async createCalendarEvent(args: any) {
|
||||||
const { summary, description, startDateTime, endDateTime, location } = args;
|
const {
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
allDay,
|
||||||
|
startDateTime,
|
||||||
|
endDateTime,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
reminderMinutesBefore,
|
||||||
|
reminderDateTime,
|
||||||
|
reminderDescription,
|
||||||
|
} = args;
|
||||||
const uid = this.generateUID();
|
const uid = this.generateUID();
|
||||||
|
const isAllDay = allDay === true;
|
||||||
|
|
||||||
|
let dtStartLine = "";
|
||||||
|
let dtEndLine = "";
|
||||||
|
|
||||||
|
if (isAllDay) {
|
||||||
|
if (!startDate) {
|
||||||
|
throw new Error("startDate is required when allDay=true");
|
||||||
|
}
|
||||||
|
const start = parseISODateOnlyLocalMidnight(startDate, "startDate");
|
||||||
|
const endInclusive = parseISODateOnlyLocalMidnight(
|
||||||
|
endDate || startDate,
|
||||||
|
"endDate"
|
||||||
|
);
|
||||||
|
if (endInclusive < start) {
|
||||||
|
throw new Error("endDate must be on or after startDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endExclusive = new Date(endInclusive.getTime());
|
||||||
|
endExclusive.setDate(endExclusive.getDate() + 1);
|
||||||
|
|
||||||
|
dtStartLine = `DTSTART;VALUE=DATE:${formatICalDate(start)}`;
|
||||||
|
dtEndLine = `DTEND;VALUE=DATE:${formatICalDate(endExclusive)}`;
|
||||||
|
} else {
|
||||||
|
if (!startDateTime || !endDateTime) {
|
||||||
|
throw new Error(
|
||||||
|
"startDateTime and endDateTime are required for timed events"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const start = new Date(startDateTime);
|
||||||
|
const end = new Date(endDateTime);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
throw new Error("startDateTime/endDateTime must be valid ISO datetime values");
|
||||||
|
}
|
||||||
|
if (end < start) {
|
||||||
|
throw new Error("endDateTime must be on or after startDateTime");
|
||||||
|
}
|
||||||
|
|
||||||
|
dtStartLine = `DTSTART:${formatICalDateTimeUtc(start)}`;
|
||||||
|
dtEndLine = `DTEND:${formatICalDateTimeUtc(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
reminderMinutesBefore !== undefined &&
|
||||||
|
reminderDateTime !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Use either reminderMinutesBefore or reminderDateTime, not both"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alarmBlock = "";
|
||||||
|
if (reminderMinutesBefore !== undefined || reminderDateTime !== undefined) {
|
||||||
|
const action =
|
||||||
|
typeof args.reminderAction === "string" && args.reminderAction.trim()
|
||||||
|
? args.reminderAction.trim().toUpperCase()
|
||||||
|
: "DISPLAY";
|
||||||
|
const alarmDescription = this.escapeICalText(
|
||||||
|
reminderDescription || "Reminder"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reminderMinutesBefore !== undefined) {
|
||||||
|
const minutes = Number(reminderMinutesBefore);
|
||||||
|
if (!Number.isFinite(minutes) || minutes < 0) {
|
||||||
|
throw new Error("reminderMinutesBefore must be a number >= 0");
|
||||||
|
}
|
||||||
|
const roundedMinutes = Math.floor(minutes);
|
||||||
|
alarmBlock = `\nBEGIN:VALARM
|
||||||
|
ACTION:${action}
|
||||||
|
TRIGGER:-PT${roundedMinutes}M
|
||||||
|
DESCRIPTION:${alarmDescription}
|
||||||
|
END:VALARM`;
|
||||||
|
} else if (reminderDateTime !== undefined) {
|
||||||
|
const reminderAt = new Date(reminderDateTime);
|
||||||
|
if (Number.isNaN(reminderAt.getTime())) {
|
||||||
|
throw new Error("reminderDateTime must be a valid ISO datetime value");
|
||||||
|
}
|
||||||
|
alarmBlock = `\nBEGIN:VALARM
|
||||||
|
ACTION:${action}
|
||||||
|
TRIGGER;VALUE=DATE-TIME:${formatICalDateTimeUtc(reminderAt)}
|
||||||
|
DESCRIPTION:${alarmDescription}
|
||||||
|
END:VALARM`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let vevent = `BEGIN:VCALENDAR
|
let vevent = `BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//Nextcloud MCP Server//EN
|
PRODID:-//Nextcloud MCP Server//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
UID:${uid}
|
UID:${uid}
|
||||||
SUMMARY:${summary}
|
SUMMARY:${this.escapeICalText(summary)}
|
||||||
DTSTART:${this.formatICalDateTime(new Date(startDateTime))}
|
${dtStartLine}
|
||||||
DTEND:${this.formatICalDateTime(new Date(endDateTime))}
|
${dtEndLine}
|
||||||
CREATED:${this.formatICalDateTime(new Date())}`;
|
CREATED:${formatICalDateTimeUtc(new Date())}`;
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
vevent += `\nDESCRIPTION:${description}`;
|
vevent += `\nDESCRIPTION:${this.escapeICalText(description)}`;
|
||||||
}
|
}
|
||||||
if (location) {
|
if (location) {
|
||||||
vevent += `\nLOCATION:${location}`;
|
vevent += `\nLOCATION:${this.escapeICalText(location)}`;
|
||||||
}
|
}
|
||||||
|
vevent += alarmBlock;
|
||||||
|
|
||||||
vevent += `\nEND:VEVENT
|
vevent += `\nEND:VEVENT
|
||||||
END:VCALENDAR`;
|
END:VCALENDAR`;
|
||||||
@@ -842,31 +974,46 @@ END:VCALENDAR`;
|
|||||||
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatICalDate(date: Date): string {
|
private escapeICalText(value: string): string {
|
||||||
return format(date, "yyyyMMdd");
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/;/g, "\\;")
|
||||||
|
.replace(/,/g, "\\,");
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatICalDateTime(date: Date): string {
|
private debugLog(message: string): void {
|
||||||
return format(date, "yyyyMMdd'T'HHmmss'Z'");
|
if (this.debugEnabled) {
|
||||||
}
|
console.error(`[DEBUG_NEXTCLOUD_MCP] ${message}`);
|
||||||
|
|
||||||
private parseICalDate(icalDate: string): string {
|
|
||||||
// Parse iCal date format (e.g., 20240101 or 20240101T120000Z)
|
|
||||||
if (icalDate.includes("T")) {
|
|
||||||
const year = icalDate.substring(0, 4);
|
|
||||||
const month = icalDate.substring(4, 6);
|
|
||||||
const day = icalDate.substring(6, 8);
|
|
||||||
const hour = icalDate.substring(9, 11);
|
|
||||||
const minute = icalDate.substring(11, 13);
|
|
||||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
||||||
} else {
|
|
||||||
const year = icalDate.substring(0, 4);
|
|
||||||
const month = icalDate.substring(4, 6);
|
|
||||||
const day = icalDate.substring(6, 8);
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async discoverCalendars(): Promise<CalendarInfo[]> {
|
||||||
|
const calendarsRoot = `/remote.php/dav/calendars/${this.config.username}/`;
|
||||||
|
const requestBody = buildCalendarDiscoveryPropfindBody();
|
||||||
|
|
||||||
|
this.debugLog(`PROPFIND calendars root: ${calendarsRoot}`);
|
||||||
|
|
||||||
|
const response = await this.axiosInstance.request({
|
||||||
|
method: "PROPFIND",
|
||||||
|
url: calendarsRoot,
|
||||||
|
data: requestBody,
|
||||||
|
headers: getCalDAVXmlHeaders("1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = String(response.data ?? "");
|
||||||
|
this.debugLog(
|
||||||
|
`PROPFIND response status=${response.status} body-preview=${responseBody.slice(
|
||||||
|
0,
|
||||||
|
500
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseCalendarsFromPROPFIND(responseBody, calendarsRoot);
|
||||||
|
}
|
||||||
|
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await this.server.connect(transport);
|
await this.server.connect(transport);
|
||||||
|
|||||||
Reference in New Issue
Block a user