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.
@@ -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(/&lt;/g, '<')
19
25
  .replace(/&gt;/g, '>')
20
- .replace(/&amp;/g, '&');
26
+ // `&amp;` last, so an encoded `&amp;#13;` decodes to the literal text
27
+ // `&#13;` rather than being decoded twice into a carriage return.
28
+ .replace(/&amp;/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, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&apos;'));
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-4-8', 'claude-sonnet-4-6')"),
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 from a vCard property line.
14
- * Handles TYPE=WORK, TYPE="WORK", type=work, etc.
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(params) {
17
- const match = params.match(/TYPE="?([^";:]+)"?/i);
18
- return match ? match[1].toUpperCase() : undefined;
15
+ function extractType(prop) {
16
+ const values = getParamValues(prop, 'TYPE');
17
+ return values.length > 0 ? values.join(',').toUpperCase() : undefined;
19
18
  }
20
- /**
21
- * Replace, add, or remove a simple vCard property.
22
- * - value === null -> remove the property
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
- * Remove all instances of a multi-valued property (e.g. EMAIL, TEL, ADR).
38
- */
39
- function removeAllVCardProperty(vcardData, propName) {
40
- const regex = new RegExp(`^${propName}(;[^:]*)?:.*\\r?\\n?`, 'gmi');
41
- return vcardData.replace(regex, '');
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 unfolded = unfoldVCardLines(vcardText);
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 line of lines) {
86
- const propMatch = line.match(/^([A-Za-z0-9-]+)(;[^:]*)?:(.*)/);
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(paramStr),
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(paramStr),
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(paramStr),
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(unfoldVCardLines(vcardData));
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
- let vcard = `BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//AIquila//MCP Server//EN\r\nUID:${contactUid}\r\nREV:${now}\r\nFN:${escapeVCardValue(args.fullName)}\r\nN:${family};${given};;${prefix};${suffix}`;
602
- if (args.emails) {
603
- for (const email of args.emails) {
604
- const typeParam = email.type ? `;TYPE=${email.type}` : '';
605
- vcard += `\r\nEMAIL${typeParam}:${email.value}`;
606
- }
607
- }
608
- if (args.phones) {
609
- for (const phone of args.phones) {
610
- const typeParam = phone.type ? `;TYPE=${phone.type}` : '';
611
- vcard += `\r\nTEL${typeParam}:${phone.value}`;
612
- }
613
- }
614
- if (args.addresses) {
615
- for (const addr of args.addresses) {
616
- const typeParam = addr.type ? `;TYPE=${addr.type}` : '';
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
- vcard += `\r\nORG:${escapeVCardValue(args.org)}`;
606
+ properties.push(property('ORG', escapeVCardValue(args.org)));
627
607
  }
628
608
  if (args.title) {
629
- vcard += `\r\nTITLE:${escapeVCardValue(args.title)}`;
609
+ properties.push(property('TITLE', escapeVCardValue(args.title)));
630
610
  }
631
611
  if (args.note) {
632
- vcard += `\r\nNOTE:${escapeVCardValue(args.note)}`;
612
+ properties.push(property('NOTE', escapeVCardValue(args.note)));
633
613
  }
634
614
  if (args.birthday) {
635
- vcard += `\r\nBDAY:${args.birthday}`;
615
+ properties.push(property('BDAY', sanitizeVCardUriValue(args.birthday)));
636
616
  }
637
617
  if (args.url) {
638
- vcard += `\r\nURL:${args.url}`;
618
+ properties.push(property('URL', sanitizeVCardUriValue(args.url)));
639
619
  }
640
620
  if (args.categories && args.categories.length > 0) {
641
- vcard += `\r\nCATEGORIES:${args.categories.map(escapeVCardValue).join(',')}`;
621
+ properties.push(property('CATEGORIES', args.categories.map(escapeVCardValue).join(',')));
642
622
  }
643
- vcard += `\r\nEND: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
- let modified = unfoldVCardLines(vcardData);
724
+ const doc = parseVCard(vcardData);
745
725
  // Update FN
746
726
  if (args.fullName !== undefined) {
747
- modified = setVCardProperty(modified, 'FN', escapeVCardValue(args.fullName));
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
- // Parse existing N property
752
- const nMatch = modified.match(/^N(;[^:]*)?:(.*)/im);
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
- existingParts[0] = args.lastName ? escapeVCardValue(args.lastName) : '';
734
+ parts[0] = args.lastName ? escapeVCardValue(args.lastName) : '';
756
735
  }
757
736
  if (args.firstName !== undefined) {
758
- existingParts[1] = args.firstName ? escapeVCardValue(args.firstName) : '';
737
+ parts[1] = args.firstName ? escapeVCardValue(args.firstName) : '';
759
738
  }
760
- modified = setVCardProperty(modified, 'N', existingParts.slice(0, 5).join(';'));
739
+ setProperty(doc, 'N', parts.slice(0, 5).join(';'));
761
740
  }
762
741
  // Update simple properties
763
742
  if (args.org !== undefined) {
764
- modified = setVCardProperty(modified, 'ORG', args.org ? escapeVCardValue(args.org) : null);
743
+ setProperty(doc, 'ORG', args.org ? escapeVCardValue(args.org) : null);
765
744
  }
766
745
  if (args.title !== undefined) {
767
- modified = setVCardProperty(modified, 'TITLE', args.title ? escapeVCardValue(args.title) : null);
746
+ setProperty(doc, 'TITLE', args.title ? escapeVCardValue(args.title) : null);
768
747
  }
769
748
  if (args.note !== undefined) {
770
- modified = setVCardProperty(modified, 'NOTE', args.note ? escapeVCardValue(args.note) : null);
749
+ setProperty(doc, 'NOTE', args.note ? escapeVCardValue(args.note) : null);
771
750
  }
772
751
  if (args.birthday !== undefined) {
773
- modified = setVCardProperty(modified, 'BDAY', args.birthday);
752
+ setProperty(doc, 'BDAY', args.birthday ? sanitizeVCardUriValue(args.birthday) : null);
774
753
  }
775
754
  if (args.url !== undefined) {
776
- modified = setVCardProperty(modified, 'URL', args.url);
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
- modified = removeAllVCardProperty(modified, 'EMAIL');
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
- modified = removeAllVCardProperty(modified, 'TEL');
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
- modified = removeAllVCardProperty(modified, 'ADR');
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
- modified = removeAllVCardProperty(modified, 'CATEGORIES');
810
- if (args.categories.length > 0) {
811
- const line = `CATEGORIES:${args.categories.map(escapeVCardValue).join(',')}`;
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
- modified = setVCardProperty(modified, 'REV', now);
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',
@@ -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>
@@ -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
- .replace(/\n/g, '\\n');
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
- return value
18
- .replace(options?.caseInsensitiveNewline ? /\\n/gi : /\\n/g, '\n')
19
- .replace(/\\,/g, ',')
20
- .replace(/\\;/g, ';')
21
- .replace(/\\\\/g, '\\');
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
4
4
  "description": "Nextcloud MCP server — files, calendar, contacts, mail, maps, notes, tasks & 120+ more tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",