aiquila-mcp 0.3.30 → 0.3.32
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/dist/client/caldav.js +24 -2
- package/dist/tools/apps/aiquila.js +1 -1
- package/dist/tools/apps/calendar.js +2 -2
- package/dist/tools/apps/contacts.js +80 -122
- package/dist/tools/apps/tasks.js +2 -2
- package/dist/tools/dav-utils.js +30 -8
- package/dist/tools/vcard.js +242 -0
- package/package.json +1 -1
package/dist/client/caldav.js
CHANGED
|
@@ -12,12 +12,34 @@ export function nsTagContent(localName) {
|
|
|
12
12
|
* Needed because we parse XML responses with regex instead of a DOM parser.
|
|
13
13
|
*/
|
|
14
14
|
export function decodeXmlEntities(text) {
|
|
15
|
-
return text
|
|
15
|
+
return (text
|
|
16
|
+
// Numeric character references. SabreDAV serializes calendar-data and
|
|
17
|
+
// address-data through libxml, which escapes carriage returns as ` `
|
|
18
|
+
// so they survive XML whitespace normalization. Leaving those undecoded
|
|
19
|
+
// corrupts every line ending of the iCalendar/vCard payload (GH #396).
|
|
20
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
21
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
16
22
|
.replace(/"/g, '"')
|
|
17
23
|
.replace(/'/g, "'")
|
|
18
24
|
.replace(/</g, '<')
|
|
19
25
|
.replace(/>/g, '>')
|
|
20
|
-
|
|
26
|
+
// `&` last, so an encoded `&#13;` decodes to the literal text
|
|
27
|
+
// ` ` rather than being decoded twice into a carriage return.
|
|
28
|
+
.replace(/&/g, '&'));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Encode text for interpolation into an XML request body.
|
|
32
|
+
* Counterpart to decodeXmlEntities — used when building CalDAV/CardDAV REPORT
|
|
33
|
+
* filters from user-supplied values (GH #402).
|
|
34
|
+
*/
|
|
35
|
+
export function encodeXmlEntities(text) {
|
|
36
|
+
return (text
|
|
37
|
+
// `&` first, otherwise the ampersands introduced below get re-encoded.
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, '''));
|
|
21
43
|
}
|
|
22
44
|
/**
|
|
23
45
|
* Fetch data from CalDAV endpoint using basic authentication
|
|
@@ -72,7 +72,7 @@ export const configureTool = {
|
|
|
72
72
|
model: z
|
|
73
73
|
.string()
|
|
74
74
|
.optional()
|
|
75
|
-
.describe("Claude model to use (e.g., 'claude-fable-5', 'claude-opus-
|
|
75
|
+
.describe("Claude model to use (e.g., 'claude-fable-5', 'claude-opus-5', 'claude-sonnet-5')"),
|
|
76
76
|
maxTokens: z.number().optional().describe('Maximum tokens for responses (default: 4096)'),
|
|
77
77
|
timeout: z.number().optional().describe('Request timeout in seconds (default: 60)'),
|
|
78
78
|
}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { decodeXmlEntities, fetchCalDAV, nsTagContent } from '../../client/caldav.js';
|
|
3
|
+
import { decodeXmlEntities, encodeXmlEntities, fetchCalDAV, nsTagContent, } from '../../client/caldav.js';
|
|
4
4
|
import { escapeICalValue, unescapeICalValue } from '../dav-utils.js';
|
|
5
5
|
import { getNextcloudConfig } from '../types.js';
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -579,7 +579,7 @@ async function resolveEventByUid(calendarName, uid) {
|
|
|
579
579
|
<c:comp-filter name="VCALENDAR">
|
|
580
580
|
<c:comp-filter name="VEVENT">
|
|
581
581
|
<c:prop-filter name="UID">
|
|
582
|
-
<c:text-match collation="i;octet">${uid}</c:text-match>
|
|
582
|
+
<c:text-match collation="i;octet">${encodeXmlEntities(uid)}</c:text-match>
|
|
583
583
|
</c:prop-filter>
|
|
584
584
|
</c:comp-filter>
|
|
585
585
|
</c:comp-filter>
|
|
@@ -1,44 +1,38 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { decodeXmlEntities, fetchCalDAV, nsTagContent } from '../../client/caldav.js';
|
|
4
|
-
import { escapeVCardValue, unescapeVCardValue } from '../dav-utils.js';
|
|
3
|
+
import { decodeXmlEntities, encodeXmlEntities, fetchCalDAV, nsTagContent, } from '../../client/caldav.js';
|
|
4
|
+
import { escapeVCardValue, sanitizeVCardUriValue, unescapeVCardValue } from '../dav-utils.js';
|
|
5
|
+
import { getParamValues, getProperty, parseVCard, property, replaceAll, serializeVCard, setProperty, } from '../vcard.js';
|
|
5
6
|
import { getNextcloudConfig } from '../types.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// vCard helpers
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
|
-
function unfoldVCardLines(text) {
|
|
10
|
-
return text.replace(/\r?\n[ \t]/g, '');
|
|
11
|
-
}
|
|
12
10
|
/**
|
|
13
|
-
* Extract TYPE parameter
|
|
14
|
-
*
|
|
11
|
+
* Extract the TYPE parameter of a property, e.g. `WORK` from `TEL;TYPE=WORK:`.
|
|
12
|
+
* vCard 4.0 may repeat the parameter (`TYPE=voice;TYPE=cell`) or quote a list
|
|
13
|
+
* (`TYPE="voice,cell"`); both collapse to a single comma-joined string.
|
|
15
14
|
*/
|
|
16
|
-
function extractType(
|
|
17
|
-
const
|
|
18
|
-
return
|
|
15
|
+
function extractType(prop) {
|
|
16
|
+
const values = getParamValues(prop, 'TYPE');
|
|
17
|
+
return values.length > 0 ? values.join(',').toUpperCase() : undefined;
|
|
19
18
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* - property exists -> replace its value
|
|
24
|
-
* - property absent -> insert before END:VCARD
|
|
25
|
-
*/
|
|
26
|
-
function setVCardProperty(vcardData, propName, value) {
|
|
27
|
-
const regex = new RegExp(`^${propName}(;[^:]*)?:.*$`, 'mi');
|
|
28
|
-
if (value === null) {
|
|
29
|
-
return vcardData.replace(regex, '').replace(/(\r?\n){2,}/g, '\r\n');
|
|
30
|
-
}
|
|
31
|
-
if (regex.test(vcardData)) {
|
|
32
|
-
return vcardData.replace(regex, `${propName}:${value}`);
|
|
33
|
-
}
|
|
34
|
-
return vcardData.replace(/END:VCARD/i, `${propName}:${value}\r\nEND:VCARD`);
|
|
19
|
+
/** Build a property with an optional TYPE parameter. */
|
|
20
|
+
function typedProperty(name, value, type) {
|
|
21
|
+
return property(name, value, type ? [['TYPE', type]] : []);
|
|
35
22
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
/** Serialize an ADR value from its structured parts, escaping each component. */
|
|
24
|
+
function buildAdrValue(addr) {
|
|
25
|
+
const part = (v) => (v ? escapeVCardValue(v) : '');
|
|
26
|
+
// ADR:po-box;extended;street;locality;region;postal-code;country
|
|
27
|
+
return [
|
|
28
|
+
'',
|
|
29
|
+
'',
|
|
30
|
+
part(addr.street),
|
|
31
|
+
part(addr.city),
|
|
32
|
+
part(addr.region),
|
|
33
|
+
part(addr.postalCode),
|
|
34
|
+
part(addr.country),
|
|
35
|
+
].join(';');
|
|
42
36
|
}
|
|
43
37
|
// ---------------------------------------------------------------------------
|
|
44
38
|
// Parsing
|
|
@@ -72,8 +66,7 @@ function parseAddressBooks(responseXml) {
|
|
|
72
66
|
* Parse a single vCard text block into a ParsedContact.
|
|
73
67
|
*/
|
|
74
68
|
function parseVCardBlock(vcardText) {
|
|
75
|
-
const
|
|
76
|
-
const lines = unfolded.split(/\r?\n/);
|
|
69
|
+
const doc = parseVCard(vcardText);
|
|
77
70
|
const contact = {
|
|
78
71
|
uid: '',
|
|
79
72
|
fullName: '',
|
|
@@ -82,14 +75,8 @@ function parseVCardBlock(vcardText) {
|
|
|
82
75
|
addresses: [],
|
|
83
76
|
categories: [],
|
|
84
77
|
};
|
|
85
|
-
for (const
|
|
86
|
-
const
|
|
87
|
-
if (!propMatch)
|
|
88
|
-
continue;
|
|
89
|
-
const [, rawName, params, rawValue] = propMatch;
|
|
90
|
-
const name = rawName.toUpperCase();
|
|
91
|
-
const value = rawValue || '';
|
|
92
|
-
const paramStr = params || '';
|
|
78
|
+
for (const prop of doc.properties) {
|
|
79
|
+
const { name, value } = prop;
|
|
93
80
|
switch (name) {
|
|
94
81
|
case 'UID':
|
|
95
82
|
contact.uid = value;
|
|
@@ -111,13 +98,13 @@ function parseVCardBlock(vcardText) {
|
|
|
111
98
|
case 'EMAIL':
|
|
112
99
|
contact.emails.push({
|
|
113
100
|
value: unescapeVCardValue(value),
|
|
114
|
-
type: extractType(
|
|
101
|
+
type: extractType(prop),
|
|
115
102
|
});
|
|
116
103
|
break;
|
|
117
104
|
case 'TEL':
|
|
118
105
|
contact.phones.push({
|
|
119
106
|
value: unescapeVCardValue(value),
|
|
120
|
-
type: extractType(
|
|
107
|
+
type: extractType(prop),
|
|
121
108
|
});
|
|
122
109
|
break;
|
|
123
110
|
case 'ADR': {
|
|
@@ -129,7 +116,7 @@ function parseVCardBlock(vcardText) {
|
|
|
129
116
|
region: unescapeVCardValue(adrParts[4] || ''),
|
|
130
117
|
postalCode: unescapeVCardValue(adrParts[5] || ''),
|
|
131
118
|
country: unescapeVCardValue(adrParts[6] || ''),
|
|
132
|
-
type: extractType(
|
|
119
|
+
type: extractType(prop),
|
|
133
120
|
});
|
|
134
121
|
break;
|
|
135
122
|
}
|
|
@@ -178,7 +165,7 @@ function parseVCards(responseXml) {
|
|
|
178
165
|
const cardDataMatch = block.match(/<(?:[a-z0-9]+:)?address-data[^>]*>([\s\S]*?)<\/(?:[a-z0-9]+:)?address-data>/);
|
|
179
166
|
if (!cardDataMatch)
|
|
180
167
|
continue;
|
|
181
|
-
const vcardText = cardDataMatch[1];
|
|
168
|
+
const vcardText = decodeXmlEntities(cardDataMatch[1]);
|
|
182
169
|
const vcardBlocks = vcardText.match(/BEGIN:VCARD[\s\S]*?END:VCARD/gi);
|
|
183
170
|
if (!vcardBlocks)
|
|
184
171
|
continue;
|
|
@@ -285,7 +272,7 @@ async function resolveContactByUid(addressBookName, uid) {
|
|
|
285
272
|
</d:prop>
|
|
286
273
|
<cr:filter>
|
|
287
274
|
<cr:prop-filter name="UID">
|
|
288
|
-
<cr:text-match collation="i;octet">${uid}</cr:text-match>
|
|
275
|
+
<cr:text-match collation="i;octet">${encodeXmlEntities(uid)}</cr:text-match>
|
|
289
276
|
</cr:prop-filter>
|
|
290
277
|
</cr:filter>
|
|
291
278
|
</cr:addressbook-query>`;
|
|
@@ -310,7 +297,7 @@ async function resolveContactByUid(addressBookName, uid) {
|
|
|
310
297
|
return {
|
|
311
298
|
href: hrefMatch[1],
|
|
312
299
|
etag,
|
|
313
|
-
vcardData: decodeXmlEntities(cardDataMatch[1]),
|
|
300
|
+
vcardData: decodeXmlEntities(cardDataMatch[1]).trim(),
|
|
314
301
|
};
|
|
315
302
|
}
|
|
316
303
|
// ---------------------------------------------------------------------------
|
|
@@ -421,7 +408,7 @@ export const listContactsTool = {
|
|
|
421
408
|
filterXml = `
|
|
422
409
|
<cr:filter>
|
|
423
410
|
<cr:prop-filter name="FN">
|
|
424
|
-
<cr:text-match collation="i;unicode-casemap" match-type="contains">${args.search}</cr:text-match>
|
|
411
|
+
<cr:text-match collation="i;unicode-casemap" match-type="contains">${encodeXmlEntities(args.search)}</cr:text-match>
|
|
425
412
|
</cr:prop-filter>
|
|
426
413
|
</cr:filter>`;
|
|
427
414
|
}
|
|
@@ -503,7 +490,7 @@ export const getContactTool = {
|
|
|
503
490
|
handler: async (args) => {
|
|
504
491
|
try {
|
|
505
492
|
const { vcardData } = await resolveContactByUid(args.addressBookName, args.uid);
|
|
506
|
-
const contact = parseVCardBlock(
|
|
493
|
+
const contact = parseVCardBlock(vcardData);
|
|
507
494
|
if (!contact) {
|
|
508
495
|
throw new Error(`Contact with UID "${args.uid}" could not be parsed`);
|
|
509
496
|
}
|
|
@@ -598,49 +585,42 @@ export const createContactTool = {
|
|
|
598
585
|
const given = args.firstName ? escapeVCardValue(args.firstName) : '';
|
|
599
586
|
const prefix = args.prefix ? escapeVCardValue(args.prefix) : '';
|
|
600
587
|
const suffix = args.suffix ? escapeVCardValue(args.suffix) : '';
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const street = addr.street ? escapeVCardValue(addr.street) : '';
|
|
618
|
-
const city = addr.city ? escapeVCardValue(addr.city) : '';
|
|
619
|
-
const region = addr.region ? escapeVCardValue(addr.region) : '';
|
|
620
|
-
const postalCode = addr.postalCode ? escapeVCardValue(addr.postalCode) : '';
|
|
621
|
-
const country = addr.country ? escapeVCardValue(addr.country) : '';
|
|
622
|
-
vcard += `\r\nADR${typeParam}:;;${street};${city};${region};${postalCode};${country}`;
|
|
623
|
-
}
|
|
588
|
+
const properties = [
|
|
589
|
+
property('VERSION', '3.0'),
|
|
590
|
+
property('PRODID', '-//AIquila//MCP Server//EN'),
|
|
591
|
+
property('UID', contactUid),
|
|
592
|
+
property('REV', now),
|
|
593
|
+
property('FN', escapeVCardValue(args.fullName)),
|
|
594
|
+
property('N', `${family};${given};;${prefix};${suffix}`),
|
|
595
|
+
];
|
|
596
|
+
for (const email of args.emails ?? []) {
|
|
597
|
+
properties.push(typedProperty('EMAIL', sanitizeVCardUriValue(email.value), email.type));
|
|
598
|
+
}
|
|
599
|
+
for (const phone of args.phones ?? []) {
|
|
600
|
+
properties.push(typedProperty('TEL', sanitizeVCardUriValue(phone.value), phone.type));
|
|
601
|
+
}
|
|
602
|
+
for (const addr of args.addresses ?? []) {
|
|
603
|
+
properties.push(typedProperty('ADR', buildAdrValue(addr), addr.type));
|
|
624
604
|
}
|
|
625
605
|
if (args.org) {
|
|
626
|
-
|
|
606
|
+
properties.push(property('ORG', escapeVCardValue(args.org)));
|
|
627
607
|
}
|
|
628
608
|
if (args.title) {
|
|
629
|
-
|
|
609
|
+
properties.push(property('TITLE', escapeVCardValue(args.title)));
|
|
630
610
|
}
|
|
631
611
|
if (args.note) {
|
|
632
|
-
|
|
612
|
+
properties.push(property('NOTE', escapeVCardValue(args.note)));
|
|
633
613
|
}
|
|
634
614
|
if (args.birthday) {
|
|
635
|
-
|
|
615
|
+
properties.push(property('BDAY', sanitizeVCardUriValue(args.birthday)));
|
|
636
616
|
}
|
|
637
617
|
if (args.url) {
|
|
638
|
-
|
|
618
|
+
properties.push(property('URL', sanitizeVCardUriValue(args.url)));
|
|
639
619
|
}
|
|
640
620
|
if (args.categories && args.categories.length > 0) {
|
|
641
|
-
|
|
621
|
+
properties.push(property('CATEGORIES', args.categories.map(escapeVCardValue).join(',')));
|
|
642
622
|
}
|
|
643
|
-
vcard
|
|
623
|
+
const vcard = serializeVCard({ properties });
|
|
644
624
|
const response = await fetchCalDAV(cardDavUrl, {
|
|
645
625
|
method: 'PUT',
|
|
646
626
|
body: vcard,
|
|
@@ -741,80 +721,58 @@ export const updateContactTool = {
|
|
|
741
721
|
try {
|
|
742
722
|
const config = getNextcloudConfig();
|
|
743
723
|
const { href, etag, vcardData } = await resolveContactByUid(args.addressBookName, args.uid);
|
|
744
|
-
|
|
724
|
+
const doc = parseVCard(vcardData);
|
|
745
725
|
// Update FN
|
|
746
726
|
if (args.fullName !== undefined) {
|
|
747
|
-
|
|
727
|
+
setProperty(doc, 'FN', escapeVCardValue(args.fullName));
|
|
748
728
|
}
|
|
749
|
-
// Update N (structured name)
|
|
729
|
+
// Update N (structured name), preserving the components we do not touch
|
|
750
730
|
if (args.firstName !== undefined || args.lastName !== undefined) {
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
const existingParts = nMatch ? nMatch[2].split(';') : ['', '', '', '', ''];
|
|
731
|
+
const existing = getProperty(doc, 'N');
|
|
732
|
+
const parts = existing ? existing.value.split(';') : ['', '', '', '', ''];
|
|
754
733
|
if (args.lastName !== undefined) {
|
|
755
|
-
|
|
734
|
+
parts[0] = args.lastName ? escapeVCardValue(args.lastName) : '';
|
|
756
735
|
}
|
|
757
736
|
if (args.firstName !== undefined) {
|
|
758
|
-
|
|
737
|
+
parts[1] = args.firstName ? escapeVCardValue(args.firstName) : '';
|
|
759
738
|
}
|
|
760
|
-
|
|
739
|
+
setProperty(doc, 'N', parts.slice(0, 5).join(';'));
|
|
761
740
|
}
|
|
762
741
|
// Update simple properties
|
|
763
742
|
if (args.org !== undefined) {
|
|
764
|
-
|
|
743
|
+
setProperty(doc, 'ORG', args.org ? escapeVCardValue(args.org) : null);
|
|
765
744
|
}
|
|
766
745
|
if (args.title !== undefined) {
|
|
767
|
-
|
|
746
|
+
setProperty(doc, 'TITLE', args.title ? escapeVCardValue(args.title) : null);
|
|
768
747
|
}
|
|
769
748
|
if (args.note !== undefined) {
|
|
770
|
-
|
|
749
|
+
setProperty(doc, 'NOTE', args.note ? escapeVCardValue(args.note) : null);
|
|
771
750
|
}
|
|
772
751
|
if (args.birthday !== undefined) {
|
|
773
|
-
|
|
752
|
+
setProperty(doc, 'BDAY', args.birthday ? sanitizeVCardUriValue(args.birthday) : null);
|
|
774
753
|
}
|
|
775
754
|
if (args.url !== undefined) {
|
|
776
|
-
|
|
755
|
+
setProperty(doc, 'URL', args.url ? sanitizeVCardUriValue(args.url) : null);
|
|
777
756
|
}
|
|
778
757
|
// Update multi-valued properties (replace all)
|
|
779
758
|
if (args.emails !== undefined) {
|
|
780
|
-
|
|
781
|
-
for (const email of args.emails) {
|
|
782
|
-
const typeParam = email.type ? `;TYPE=${email.type}` : '';
|
|
783
|
-
const line = `EMAIL${typeParam}:${email.value}`;
|
|
784
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
785
|
-
}
|
|
759
|
+
replaceAll(doc, 'EMAIL', args.emails.map((e) => typedProperty('EMAIL', sanitizeVCardUriValue(e.value), e.type)));
|
|
786
760
|
}
|
|
787
761
|
if (args.phones !== undefined) {
|
|
788
|
-
|
|
789
|
-
for (const phone of args.phones) {
|
|
790
|
-
const typeParam = phone.type ? `;TYPE=${phone.type}` : '';
|
|
791
|
-
const line = `TEL${typeParam}:${phone.value}`;
|
|
792
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
793
|
-
}
|
|
762
|
+
replaceAll(doc, 'TEL', args.phones.map((p) => typedProperty('TEL', sanitizeVCardUriValue(p.value), p.type)));
|
|
794
763
|
}
|
|
795
764
|
if (args.addresses !== undefined) {
|
|
796
|
-
|
|
797
|
-
for (const addr of args.addresses) {
|
|
798
|
-
const typeParam = addr.type ? `;TYPE=${addr.type}` : '';
|
|
799
|
-
const street = addr.street ? escapeVCardValue(addr.street) : '';
|
|
800
|
-
const city = addr.city ? escapeVCardValue(addr.city) : '';
|
|
801
|
-
const region = addr.region ? escapeVCardValue(addr.region) : '';
|
|
802
|
-
const postalCode = addr.postalCode ? escapeVCardValue(addr.postalCode) : '';
|
|
803
|
-
const country = addr.country ? escapeVCardValue(addr.country) : '';
|
|
804
|
-
const line = `ADR${typeParam}:;;${street};${city};${region};${postalCode};${country}`;
|
|
805
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
806
|
-
}
|
|
765
|
+
replaceAll(doc, 'ADR', args.addresses.map((a) => typedProperty('ADR', buildAdrValue(a), a.type)));
|
|
807
766
|
}
|
|
808
767
|
if (args.categories !== undefined) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
813
|
-
}
|
|
768
|
+
replaceAll(doc, 'CATEGORIES', args.categories.length > 0
|
|
769
|
+
? [property('CATEGORIES', args.categories.map(escapeVCardValue).join(','))]
|
|
770
|
+
: []);
|
|
814
771
|
}
|
|
815
772
|
// Update REV timestamp
|
|
816
773
|
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
817
|
-
|
|
774
|
+
setProperty(doc, 'REV', now);
|
|
775
|
+
const modified = serializeVCard(doc);
|
|
818
776
|
const putUrl = `${config.url}${href}`;
|
|
819
777
|
const putResponse = await fetchCalDAV(putUrl, {
|
|
820
778
|
method: 'PUT',
|
package/dist/tools/apps/tasks.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { decodeXmlEntities, fetchCalDAV, nsTagContent } from '../../client/caldav.js';
|
|
3
|
+
import { decodeXmlEntities, encodeXmlEntities, fetchCalDAV, nsTagContent, } from '../../client/caldav.js';
|
|
4
4
|
import { escapeICalValue } from '../dav-utils.js';
|
|
5
5
|
import { getNextcloudConfig } from '../types.js';
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -240,7 +240,7 @@ async function resolveTaskByUid(calendarName, uid) {
|
|
|
240
240
|
<c:comp-filter name="VCALENDAR">
|
|
241
241
|
<c:comp-filter name="VTODO">
|
|
242
242
|
<c:prop-filter name="UID">
|
|
243
|
-
<c:text-match collation="i;octet">${uid}</c:text-match>
|
|
243
|
+
<c:text-match collation="i;octet">${encodeXmlEntities(uid)}</c:text-match>
|
|
244
244
|
</c:prop-filter>
|
|
245
245
|
</c:comp-filter>
|
|
246
246
|
</c:comp-filter>
|
package/dist/tools/dav-utils.js
CHANGED
|
@@ -7,24 +7,46 @@
|
|
|
7
7
|
* case-insensitive for that sequence.
|
|
8
8
|
*/
|
|
9
9
|
export function escapeDavValue(value) {
|
|
10
|
-
return value
|
|
10
|
+
return (value
|
|
11
11
|
.replace(/\\/g, '\\\\')
|
|
12
12
|
.replace(/;/g, '\\;')
|
|
13
13
|
.replace(/,/g, '\\,')
|
|
14
|
-
|
|
14
|
+
// A raw CR would terminate the content line and make the payload
|
|
15
|
+
// unparseable, so CRLF and lone CR both collapse to an escaped newline.
|
|
16
|
+
.replace(/\r\n|\r|\n/g, '\\n'));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize a value for a URI- or date-valued property (URL, TEL, EMAIL, BDAY).
|
|
20
|
+
*
|
|
21
|
+
* Text escaping must NOT be applied to these: `;` and `,` carry no special
|
|
22
|
+
* meaning in their values, and escaping them leaves a literal backslash in the
|
|
23
|
+
* URI once a strict parser reads it back. All that is actually required is that
|
|
24
|
+
* the value cannot terminate the content line.
|
|
25
|
+
*/
|
|
26
|
+
export function sanitizeDavUriValue(value) {
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
return value.replace(/[\x00-\x1f\x7f]/g, '');
|
|
15
29
|
}
|
|
16
30
|
export function unescapeDavValue(value, options) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
// Single pass: a chained replace would rewrite the `\n` inside an escaped
|
|
32
|
+
// backslash sequence (`\\n` — a literal backslash followed by `n`) into a
|
|
33
|
+
// newline before the backslash itself was unescaped.
|
|
34
|
+
const newlinePattern = options?.caseInsensitiveNewline ? /n/i : /n/;
|
|
35
|
+
return value.replace(/\\(.)/g, (match, char) => {
|
|
36
|
+
if (newlinePattern.test(char))
|
|
37
|
+
return '\n';
|
|
38
|
+
if (char === ',' || char === ';' || char === '\\')
|
|
39
|
+
return char;
|
|
40
|
+
return match;
|
|
41
|
+
});
|
|
22
42
|
}
|
|
23
43
|
/** Escape for iCalendar (VEVENT, VTODO) properties. */
|
|
24
44
|
export const escapeICalValue = escapeDavValue;
|
|
25
45
|
/** Unescape iCalendar property values (case-sensitive \\n). */
|
|
26
46
|
export const unescapeICalValue = (value) => unescapeDavValue(value);
|
|
27
|
-
/** Escape for vCard properties. */
|
|
47
|
+
/** Escape for vCard text properties (FN, N, ADR, ORG, NOTE, CATEGORIES, ...). */
|
|
28
48
|
export const escapeVCardValue = escapeDavValue;
|
|
49
|
+
/** Sanitize vCard URI/date properties (URL, TEL, EMAIL, BDAY) — no escaping. */
|
|
50
|
+
export const sanitizeVCardUriValue = sanitizeDavUriValue;
|
|
29
51
|
/** Unescape vCard property values (case-insensitive \\n per RFC 6350). */
|
|
30
52
|
export const unescapeVCardValue = (value) => unescapeDavValue(value, { caseInsensitiveNewline: true });
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Minimal vCard (RFC 6350 / RFC 2426) parser and serializer.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the regex string-surgery that used to mutate vCard text in place.
|
|
6
|
+
* That approach produced bodies SabreDAV rejected with `415 Unsupported Media
|
|
7
|
+
* Type` (GH #396): it mixed line endings, never re-folded long lines, dropped
|
|
8
|
+
* property parameters, and could not see grouped properties (`item1.TEL`).
|
|
9
|
+
*
|
|
10
|
+
* The model is deliberately flat: a vCard is an ordered list of properties,
|
|
11
|
+
* including the `BEGIN`, `VERSION` and `END` lines. That keeps round-tripping a
|
|
12
|
+
* card we did not author byte-stable apart from re-folding, which matters
|
|
13
|
+
* because we PUT back whatever the server gave us.
|
|
14
|
+
*/
|
|
15
|
+
/** Maximum octets per line before folding (RFC 6350 §3.2). */
|
|
16
|
+
const MAX_LINE_OCTETS = 75;
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Parsing
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Split a content line at the first colon that is not inside a quoted
|
|
22
|
+
* parameter value. `TEL;TYPE="voice:work";VALUE=uri:tel:+41...` must split at
|
|
23
|
+
* the colon before `tel:`, not at either of the earlier ones.
|
|
24
|
+
*/
|
|
25
|
+
function splitAtValueColon(line) {
|
|
26
|
+
let inQuotes = false;
|
|
27
|
+
for (let i = 0; i < line.length; i++) {
|
|
28
|
+
const ch = line[i];
|
|
29
|
+
if (ch === '"') {
|
|
30
|
+
inQuotes = !inQuotes;
|
|
31
|
+
}
|
|
32
|
+
else if (ch === ':' && !inQuotes) {
|
|
33
|
+
return [line.slice(0, i), line.slice(i + 1)];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Split the part before the value colon on unquoted semicolons. */
|
|
39
|
+
function splitParams(head) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
let current = '';
|
|
42
|
+
let inQuotes = false;
|
|
43
|
+
for (const ch of head) {
|
|
44
|
+
if (ch === '"') {
|
|
45
|
+
inQuotes = !inQuotes;
|
|
46
|
+
current += ch;
|
|
47
|
+
}
|
|
48
|
+
else if (ch === ';' && !inQuotes) {
|
|
49
|
+
segments.push(current);
|
|
50
|
+
current = '';
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
current += ch;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
segments.push(current);
|
|
57
|
+
return segments;
|
|
58
|
+
}
|
|
59
|
+
function parseLine(line) {
|
|
60
|
+
const split = splitAtValueColon(line);
|
|
61
|
+
if (!split)
|
|
62
|
+
return null;
|
|
63
|
+
const [head, value] = split;
|
|
64
|
+
const segments = splitParams(head);
|
|
65
|
+
const nameToken = segments[0];
|
|
66
|
+
if (!nameToken)
|
|
67
|
+
return null;
|
|
68
|
+
let group;
|
|
69
|
+
let name = nameToken;
|
|
70
|
+
const dot = nameToken.indexOf('.');
|
|
71
|
+
if (dot > 0) {
|
|
72
|
+
group = nameToken.slice(0, dot);
|
|
73
|
+
name = nameToken.slice(dot + 1);
|
|
74
|
+
}
|
|
75
|
+
if (!name)
|
|
76
|
+
return null;
|
|
77
|
+
const params = [];
|
|
78
|
+
for (const segment of segments.slice(1)) {
|
|
79
|
+
if (!segment)
|
|
80
|
+
continue;
|
|
81
|
+
const eq = segment.indexOf('=');
|
|
82
|
+
if (eq === -1) {
|
|
83
|
+
params.push([segment, '']);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
params.push([segment.slice(0, eq), segment.slice(eq + 1)]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const upper = name.toUpperCase();
|
|
90
|
+
return {
|
|
91
|
+
group,
|
|
92
|
+
name: upper,
|
|
93
|
+
...(name === upper ? {} : { rawName: name }),
|
|
94
|
+
params,
|
|
95
|
+
value,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse vCard text into a document.
|
|
100
|
+
*
|
|
101
|
+
* Normalizes all line-ending flavours, unfolds continuation lines, and skips
|
|
102
|
+
* blank lines. Unparseable lines are dropped rather than throwing: we are
|
|
103
|
+
* usually handling a card written by some other client and would rather
|
|
104
|
+
* preserve the rest than fail the whole update.
|
|
105
|
+
*/
|
|
106
|
+
export function parseVCard(text) {
|
|
107
|
+
const unfolded = text
|
|
108
|
+
.replace(/\r\n|\r/g, '\n')
|
|
109
|
+
.replace(/\n[ \t]/g, '')
|
|
110
|
+
.trim();
|
|
111
|
+
const properties = [];
|
|
112
|
+
for (const line of unfolded.split('\n')) {
|
|
113
|
+
if (!line.trim())
|
|
114
|
+
continue;
|
|
115
|
+
const property = parseLine(line);
|
|
116
|
+
if (property)
|
|
117
|
+
properties.push(property);
|
|
118
|
+
}
|
|
119
|
+
return { properties };
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Serialization
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/**
|
|
125
|
+
* Fold a content line to {@link MAX_LINE_OCTETS} octets, splitting only on
|
|
126
|
+
* whole code points so multi-byte UTF-8 sequences are never cut in half.
|
|
127
|
+
* Continuation lines carry a single leading space, which counts toward the
|
|
128
|
+
* limit.
|
|
129
|
+
*/
|
|
130
|
+
function foldLine(line) {
|
|
131
|
+
if (Buffer.byteLength(line, 'utf8') <= MAX_LINE_OCTETS)
|
|
132
|
+
return line;
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let current = '';
|
|
135
|
+
let currentOctets = 0;
|
|
136
|
+
// First line has the full budget; continuation lines lose one octet to the
|
|
137
|
+
// leading space.
|
|
138
|
+
let limit = MAX_LINE_OCTETS;
|
|
139
|
+
for (const char of line) {
|
|
140
|
+
const octets = Buffer.byteLength(char, 'utf8');
|
|
141
|
+
if (currentOctets + octets > limit) {
|
|
142
|
+
chunks.push(current);
|
|
143
|
+
current = '';
|
|
144
|
+
currentOctets = 0;
|
|
145
|
+
limit = MAX_LINE_OCTETS - 1;
|
|
146
|
+
}
|
|
147
|
+
current += char;
|
|
148
|
+
currentOctets += octets;
|
|
149
|
+
}
|
|
150
|
+
chunks.push(current);
|
|
151
|
+
return chunks.join('\r\n ');
|
|
152
|
+
}
|
|
153
|
+
function serializeProperty(property) {
|
|
154
|
+
const bare = property.rawName ?? property.name;
|
|
155
|
+
const name = property.group ? `${property.group}.${bare}` : bare;
|
|
156
|
+
const params = property.params
|
|
157
|
+
.map(([key, value]) => (value === '' ? `;${key}` : `;${key}=${value}`))
|
|
158
|
+
.join('');
|
|
159
|
+
return foldLine(`${name}${params}:${property.value}`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Serialize a document to wire format: CRLF throughout, folded, and with the
|
|
163
|
+
* structural properties forced into the order SabreDAV's strict MimeDir parser
|
|
164
|
+
* expects (`BEGIN` first, `VERSION` second, `END` last).
|
|
165
|
+
*/
|
|
166
|
+
export function serializeVCard(doc) {
|
|
167
|
+
const body = doc.properties.filter((p) => p.name !== 'BEGIN' && p.name !== 'END' && p.name !== 'VERSION');
|
|
168
|
+
const version = doc.properties.find((p) => p.name === 'VERSION');
|
|
169
|
+
const ordered = [
|
|
170
|
+
{ name: 'BEGIN', params: [], value: 'VCARD' },
|
|
171
|
+
version ?? { name: 'VERSION', params: [], value: '3.0' },
|
|
172
|
+
...body,
|
|
173
|
+
{ name: 'END', params: [], value: 'VCARD' },
|
|
174
|
+
];
|
|
175
|
+
return ordered.map(serializeProperty).join('\r\n') + '\r\n';
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Mutation helpers
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/** Build a property, defaulting the parameter list. */
|
|
181
|
+
export function property(name, value, params = []) {
|
|
182
|
+
return { name: name.toUpperCase(), params, value };
|
|
183
|
+
}
|
|
184
|
+
/** First property with the given name, in any group. */
|
|
185
|
+
export function getProperty(doc, name) {
|
|
186
|
+
const upper = name.toUpperCase();
|
|
187
|
+
return doc.properties.find((p) => p.name === upper);
|
|
188
|
+
}
|
|
189
|
+
/** All values of a parameter across a property's (possibly repeated) entries. */
|
|
190
|
+
export function getParamValues(prop, paramName) {
|
|
191
|
+
const upper = paramName.toUpperCase();
|
|
192
|
+
return prop.params
|
|
193
|
+
.filter(([key]) => key.toUpperCase() === upper)
|
|
194
|
+
.flatMap(([, value]) => value.replace(/^"|"$/g, '').split(','))
|
|
195
|
+
.map((v) => v.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
/** Index just before the terminating `END:VCARD`, or the end of the list. */
|
|
199
|
+
function insertionIndex(doc) {
|
|
200
|
+
const end = doc.properties.findIndex((p) => p.name === 'END');
|
|
201
|
+
return end === -1 ? doc.properties.length : end;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Replace the value of a single-valued property, or remove it when `value` is
|
|
205
|
+
* null. Existing parameters and the group prefix are preserved — replacing
|
|
206
|
+
* `NOTE;CHARSET=UTF-8:old` must not silently produce `NOTE:new`.
|
|
207
|
+
*/
|
|
208
|
+
export function setProperty(doc, name, value) {
|
|
209
|
+
const upper = name.toUpperCase();
|
|
210
|
+
if (value === null) {
|
|
211
|
+
doc.properties = doc.properties.filter((p) => p.name !== upper);
|
|
212
|
+
removeOrphanedLabels(doc);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const existing = doc.properties.find((p) => p.name === upper);
|
|
216
|
+
if (existing) {
|
|
217
|
+
existing.value = value;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
doc.properties.splice(insertionIndex(doc), 0, property(upper, value));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Replace every instance of a multi-valued property (EMAIL, TEL, ADR, ...)
|
|
224
|
+
* with a new set. Grouped instances are removed too, so re-setting phones does
|
|
225
|
+
* not leave the Contacts app's `item1.TEL` entries behind alongside the new
|
|
226
|
+
* ones.
|
|
227
|
+
*/
|
|
228
|
+
export function replaceAll(doc, name, replacements) {
|
|
229
|
+
const upper = name.toUpperCase();
|
|
230
|
+
doc.properties = doc.properties.filter((p) => p.name !== upper);
|
|
231
|
+
removeOrphanedLabels(doc);
|
|
232
|
+
doc.properties.splice(insertionIndex(doc), 0, ...replacements);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Drop `X-ABLabel` properties whose group no longer has anything to label.
|
|
236
|
+
* Removing `item1.TEL` without this leaves a dangling `item1.X-ABLabel`, which
|
|
237
|
+
* the Contacts app renders as an empty labelled field.
|
|
238
|
+
*/
|
|
239
|
+
function removeOrphanedLabels(doc) {
|
|
240
|
+
const groupsInUse = new Set(doc.properties.filter((p) => p.group && p.name !== 'X-ABLABEL').map((p) => p.group));
|
|
241
|
+
doc.properties = doc.properties.filter((p) => p.name !== 'X-ABLABEL' || !p.group || groupsInUse.has(p.group));
|
|
242
|
+
}
|