aiquila-mcp 0.3.6 → 0.3.7

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.
@@ -52,17 +52,129 @@ function setICalProperty(icalData, propName, value) {
52
52
  }
53
53
  return icalData.replace(/END:VEVENT/, `${propName}:${value}\r\nEND:VEVENT`);
54
54
  }
55
- function setICalDateProperty(icalData, propName, value) {
55
+ function setICalDateProperty(icalData, propName, value, tzid) {
56
56
  const regex = new RegExp(`^${propName}(;[^:]*)?:.*$`, 'm');
57
57
  if (value === null) {
58
58
  return icalData.replace(regex, '').replace(/(\r?\n){2,}/g, '\r\n');
59
59
  }
60
- const formatted = value.length === 8 ? `${propName};VALUE=DATE:${value}` : `${propName}:${value}`;
60
+ let formatted;
61
+ if (value.length === 8) {
62
+ formatted = `${propName};VALUE=DATE:${value}`;
63
+ }
64
+ else if (tzid) {
65
+ formatted = `${propName};TZID=${tzid}:${normalizeLocalICal(value, tzid)}`;
66
+ }
67
+ else {
68
+ formatted = `${propName}:${value}`;
69
+ }
61
70
  if (regex.test(icalData)) {
62
71
  return icalData.replace(regex, formatted);
63
72
  }
64
73
  return icalData.replace(/END:VEVENT/, `${formatted}\r\nEND:VEVENT`);
65
74
  }
75
+ /**
76
+ * Format the UTC offset for a given IANA zone at a specific instant.
77
+ * Returns "+HHMM" or "-HHMM" (e.g. "+0200", "-0500", "+0000").
78
+ */
79
+ function getTimezoneOffset(tzid, date) {
80
+ const fmt = new Intl.DateTimeFormat('en-US', {
81
+ timeZone: tzid,
82
+ timeZoneName: 'longOffset',
83
+ });
84
+ const tzPart = fmt.formatToParts(date).find((p) => p.type === 'timeZoneName')?.value || 'GMT';
85
+ // tzPart is "GMT", "GMT+02:00", "GMT-05:30", or "GMT+5:45" depending on engine
86
+ const m = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
87
+ if (!m)
88
+ return '+0000';
89
+ const sign = m[1];
90
+ const hh = m[2].padStart(2, '0');
91
+ const mm = m[3] ?? '00';
92
+ return `${sign}${hh}${mm}`;
93
+ }
94
+ /**
95
+ * Build a minimal but RFC 5545-conformant VTIMEZONE component for the given
96
+ * IANA zone. Uses two reference instants in the current year to detect DST.
97
+ * No external tzdata required — sufficient for iOS / Thunderbird to accept
98
+ * the event and apply the TZID correctly to upcoming occurrences.
99
+ */
100
+ export function buildVTimezone(tzid) {
101
+ const year = new Date().getUTCFullYear();
102
+ const janOffset = getTimezoneOffset(tzid, new Date(Date.UTC(year, 0, 15)));
103
+ const julOffset = getTimezoneOffset(tzid, new Date(Date.UTC(year, 6, 15)));
104
+ const lines = ['BEGIN:VTIMEZONE', `TZID:${tzid}`];
105
+ if (janOffset === julOffset) {
106
+ lines.push('BEGIN:STANDARD', 'DTSTART:19700101T000000', `TZOFFSETFROM:${janOffset}`, `TZOFFSETTO:${janOffset}`, `TZNAME:${tzid}`, 'END:STANDARD');
107
+ }
108
+ else {
109
+ // Higher numeric offset = daylight, lower = standard (works for both hemispheres)
110
+ const standardOffset = janOffset < julOffset ? janOffset : julOffset;
111
+ const daylightOffset = janOffset < julOffset ? julOffset : janOffset;
112
+ lines.push('BEGIN:STANDARD', 'DTSTART:19701101T000000', `TZOFFSETFROM:${daylightOffset}`, `TZOFFSETTO:${standardOffset}`, `TZNAME:${tzid}`, 'END:STANDARD', 'BEGIN:DAYLIGHT', 'DTSTART:19700301T000000', `TZOFFSETFROM:${standardOffset}`, `TZOFFSETTO:${daylightOffset}`, `TZNAME:${tzid}`, 'END:DAYLIGHT');
113
+ }
114
+ lines.push('END:VTIMEZONE');
115
+ return lines.join('\r\n');
116
+ }
117
+ /**
118
+ * Convert a UTC iCal datetime ("YYYYMMDDTHHmmssZ") to wall-clock time in the
119
+ * given IANA zone, returning "YYYYMMDDTHHmmss" (no Z).
120
+ */
121
+ function convertUtcICalToLocalICal(utcICal, tzid) {
122
+ const m = utcICal.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
123
+ if (!m)
124
+ return utcICal.replace(/Z$/, '');
125
+ const [, y, mo, d, h, mi, s] = m;
126
+ const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
127
+ const fmt = new Intl.DateTimeFormat('en-CA', {
128
+ timeZone: tzid,
129
+ year: 'numeric',
130
+ month: '2-digit',
131
+ day: '2-digit',
132
+ hour: '2-digit',
133
+ minute: '2-digit',
134
+ second: '2-digit',
135
+ hour12: false,
136
+ });
137
+ const parts = fmt.formatToParts(instant);
138
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? '00';
139
+ let hour = get('hour');
140
+ if (hour === '24')
141
+ hour = '00';
142
+ return `${get('year')}${get('month')}${get('day')}T${hour}${get('minute')}${get('second')}`;
143
+ }
144
+ /**
145
+ * If `value` ends in Z, convert it to wall-clock time in `tzid`. Otherwise
146
+ * assume it is already a floating local datetime in that zone.
147
+ */
148
+ function normalizeLocalICal(value, tzid) {
149
+ if (/Z$/.test(value))
150
+ return convertUtcICalToLocalICal(value, tzid);
151
+ return value;
152
+ }
153
+ /**
154
+ * Add `hours` to a floating "YYYYMMDDTHHmmss" string, returning the same form.
155
+ */
156
+ function addHoursToLocalICal(local, hours) {
157
+ const m = local.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
158
+ if (!m)
159
+ return local;
160
+ const [, y, mo, d, h, mi, s] = m;
161
+ const dt = new Date(Date.UTC(+y, +mo - 1, +d, +h + hours, +mi, +s));
162
+ const pad = (n) => String(n).padStart(2, '0');
163
+ return (`${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}` +
164
+ `T${pad(dt.getUTCHours())}${pad(dt.getUTCMinutes())}${pad(dt.getUTCSeconds())}`);
165
+ }
166
+ /**
167
+ * Ensure the VCALENDAR contains a VTIMEZONE with the requested TZID. Insert
168
+ * one before the first VEVENT if not already present.
169
+ */
170
+ function ensureVTimezone(icalData, tzid) {
171
+ const escaped = tzid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
+ const existing = new RegExp(`BEGIN:VTIMEZONE[\\s\\S]*?TZID:${escaped}[\\s\\S]*?END:VTIMEZONE`);
173
+ if (existing.test(icalData))
174
+ return icalData;
175
+ const vtz = buildVTimezone(tzid);
176
+ return icalData.replace(/BEGIN:VEVENT/, `${vtz}\r\nBEGIN:VEVENT`);
177
+ }
66
178
  // ---------------------------------------------------------------------------
67
179
  // Parsing
68
180
  // ---------------------------------------------------------------------------
@@ -703,6 +815,10 @@ export const createEventTool = {
703
815
  .number()
704
816
  .optional()
705
817
  .describe('Reminder in minutes before the event (e.g. 15 for 15 min before)'),
818
+ tzid: z
819
+ .string()
820
+ .optional()
821
+ .describe('IANA time zone (e.g. "Europe/Berlin"). When set, a VTIMEZONE block is emitted and dtstart/dtend use TZID instead of UTC. Required for reliable iOS Calendar and Thunderbird CalDAV sync of timed events. UTC inputs (trailing Z) are converted to wall-clock time in this zone.'),
706
822
  }),
707
823
  handler: async (args) => {
708
824
  try {
@@ -714,6 +830,8 @@ export const createEventTool = {
714
830
  await assertCalendarSupportsEvents(calendarBaseUrl, args.calendarName);
715
831
  const now = icalNow();
716
832
  const isAllDay = args.dtstart.length === 8;
833
+ const tzid = !isAllDay ? args.tzid : undefined;
834
+ const localDtstart = tzid ? normalizeLocalICal(args.dtstart, tzid) : args.dtstart;
717
835
  // Calculate default end
718
836
  let dtend = args.dtend;
719
837
  if (!dtend) {
@@ -722,18 +840,34 @@ export const createEventTool = {
722
840
  const d = new Date(parseInt(args.dtstart.slice(0, 4)), parseInt(args.dtstart.slice(4, 6)) - 1, parseInt(args.dtstart.slice(6, 8)) + 1);
723
841
  dtend = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
724
842
  }
843
+ else if (tzid) {
844
+ // Timed with TZID: 1 hour after local wall time
845
+ dtend = addHoursToLocalICal(localDtstart, 1);
846
+ }
725
847
  else {
726
- // Timed: 1 hour later
848
+ // Timed: 1 hour later (UTC)
727
849
  const startDate = new Date(parseInt(args.dtstart.slice(0, 4)), parseInt(args.dtstart.slice(4, 6)) - 1, parseInt(args.dtstart.slice(6, 8)), parseInt(args.dtstart.slice(9, 11)), parseInt(args.dtstart.slice(11, 13)), parseInt(args.dtstart.slice(13, 15)));
728
850
  startDate.setHours(startDate.getHours() + 1);
729
851
  dtend = startDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
730
852
  }
731
853
  }
732
- const dtstartProp = isAllDay
733
- ? `DTSTART;VALUE=DATE:${args.dtstart}`
734
- : `DTSTART:${args.dtstart}`;
735
- const dtendProp = isAllDay ? `DTEND;VALUE=DATE:${dtend}` : `DTEND:${dtend}`;
736
- let vevent = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//AIquila//MCP Server//EN\r\nBEGIN:VEVENT\r\nUID:${eventUid}\r\nDTSTAMP:${now}\r\nCREATED:${now}\r\nLAST-MODIFIED:${now}\r\n${dtstartProp}\r\n${dtendProp}\r\nSUMMARY:${escapeICalValue(args.summary)}`;
854
+ const localDtend = tzid ? normalizeLocalICal(dtend, tzid) : dtend;
855
+ let dtstartProp;
856
+ let dtendProp;
857
+ if (isAllDay) {
858
+ dtstartProp = `DTSTART;VALUE=DATE:${args.dtstart}`;
859
+ dtendProp = `DTEND;VALUE=DATE:${dtend}`;
860
+ }
861
+ else if (tzid) {
862
+ dtstartProp = `DTSTART;TZID=${tzid}:${localDtstart}`;
863
+ dtendProp = `DTEND;TZID=${tzid}:${localDtend}`;
864
+ }
865
+ else {
866
+ dtstartProp = `DTSTART:${args.dtstart}`;
867
+ dtendProp = `DTEND:${dtend}`;
868
+ }
869
+ const vtimezoneBlock = tzid ? `${buildVTimezone(tzid)}\r\n` : '';
870
+ let vevent = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//AIquila//MCP Server//EN\r\n${vtimezoneBlock}BEGIN:VEVENT\r\nUID:${eventUid}\r\nDTSTAMP:${now}\r\nCREATED:${now}\r\nLAST-MODIFIED:${now}\r\n${dtstartProp}\r\n${dtendProp}\r\nSUMMARY:${escapeICalValue(args.summary)}`;
737
871
  if (args.location) {
738
872
  vevent += `\r\nLOCATION:${escapeICalValue(args.location)}`;
739
873
  }
@@ -870,6 +1004,10 @@ export const updateEventTool = {
870
1004
  .nullable()
871
1005
  .optional()
872
1006
  .describe('Replace attendees list, or null to remove all attendees'),
1007
+ tzid: z
1008
+ .string()
1009
+ .optional()
1010
+ .describe('IANA time zone (e.g. "Europe/Berlin") for the updated dtstart/dtend. When set, a VTIMEZONE block is added (if missing) and DTSTART/DTEND use TZID instead of UTC. Required for iOS/Thunderbird CalDAV sync.'),
873
1011
  }),
874
1012
  handler: async (args) => {
875
1013
  try {
@@ -879,11 +1017,17 @@ export const updateEventTool = {
879
1017
  if (args.summary !== undefined) {
880
1018
  modified = setICalProperty(modified, 'SUMMARY', escapeICalValue(args.summary));
881
1019
  }
1020
+ // Only timed datetimes carry a TZID; all-day (length 8) values are dates.
1021
+ const dtstartTz = args.tzid && args.dtstart && args.dtstart.length > 8 ? args.tzid : undefined;
1022
+ const dtendTz = args.tzid && args.dtend && args.dtend.length > 8 ? args.tzid : undefined;
882
1023
  if (args.dtstart !== undefined) {
883
- modified = setICalDateProperty(modified, 'DTSTART', args.dtstart);
1024
+ modified = setICalDateProperty(modified, 'DTSTART', args.dtstart, dtstartTz);
884
1025
  }
885
1026
  if (args.dtend !== undefined) {
886
- modified = setICalDateProperty(modified, 'DTEND', args.dtend);
1027
+ modified = setICalDateProperty(modified, 'DTEND', args.dtend, dtendTz);
1028
+ }
1029
+ if (args.tzid && (dtstartTz || dtendTz)) {
1030
+ modified = ensureVTimezone(modified, args.tzid);
887
1031
  }
888
1032
  if (args.location !== undefined) {
889
1033
  modified = setICalProperty(modified, 'LOCATION', args.location ? escapeICalValue(args.location) : null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "AIquila - MCP server for Nextcloud integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",