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.
- package/dist/tools/apps/calendar.js +154 -10
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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);
|