mcpbrowser 0.3.34 → 0.3.36
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/package.json +1 -1
- package/src/actions/click-element.js +8 -3
- package/src/actions/execute-javascript.js +8 -3
- package/src/actions/fetch-page.js +9 -3
- package/src/actions/get-current-html.js +9 -3
- package/src/actions/plugin-action.js +180 -0
- package/src/actions/plugin-info.js +170 -0
- package/src/core/logger.js +3 -7
- package/src/core/plugin-loader.js +344 -0
- package/src/mcp-browser.js +34 -2
- package/src/plugins/_example/index.js +140 -0
- package/src/plugins/gcal/actions/check-availability.js +185 -0
- package/src/plugins/gcal/actions/create-event.js +238 -0
- package/src/plugins/gcal/actions/delete-event.js +138 -0
- package/src/plugins/gcal/actions/edit-event.js +244 -0
- package/src/plugins/gcal/actions/list-events.js +96 -0
- package/src/plugins/gcal/actions/read-event.js +174 -0
- package/src/plugins/gcal/actions/rsvp-event.js +149 -0
- package/src/plugins/gcal/actions/search-events.js +121 -0
- package/src/plugins/gcal/helpers.js +415 -0
- package/src/plugins/gcal/index.js +148 -0
- package/src/plugins/gcal/selectors.js +54 -0
- package/src/plugins/gmail/actions/archive-email.js +65 -0
- package/src/plugins/gmail/actions/compose-email.js +116 -0
- package/src/plugins/gmail/actions/delete-email.js +65 -0
- package/src/plugins/gmail/actions/forward-email.js +95 -0
- package/src/plugins/gmail/actions/label-email.js +107 -0
- package/src/plugins/gmail/actions/list-emails.js +61 -0
- package/src/plugins/gmail/actions/mark-read.js +71 -0
- package/src/plugins/gmail/actions/mark-unread.js +71 -0
- package/src/plugins/gmail/actions/read-email.js +149 -0
- package/src/plugins/gmail/actions/reply-email.js +87 -0
- package/src/plugins/gmail/actions/search-emails.js +95 -0
- package/src/plugins/gmail/helpers.js +419 -0
- package/src/plugins/gmail/index.js +195 -0
- package/src/plugins/gmail/selectors.js +82 -0
- package/src/plugins.json +4 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-availability.js — Check whether a time slot is free or busy.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T1: calendarNavigate + buildViewPath to navigate to day view for the date
|
|
6
|
+
* T3: extractVisibleEvents for event data extraction
|
|
7
|
+
* T4: EVENT_CHIP selector via waitForCalendar
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
11
|
+
import logger from '../../../core/logger.js';
|
|
12
|
+
import {
|
|
13
|
+
checkPrecondition,
|
|
14
|
+
calendarNavigate,
|
|
15
|
+
buildViewPath,
|
|
16
|
+
waitForCalendar,
|
|
17
|
+
extractVisibleEvents,
|
|
18
|
+
GCalActionResponse
|
|
19
|
+
} from '../helpers.js';
|
|
20
|
+
import { EVENT_CHIP } from '../selectors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse HH:MM time string to total minutes since midnight.
|
|
24
|
+
* @param {string} timeStr - Time in HH:MM format
|
|
25
|
+
* @returns {number} Minutes since midnight
|
|
26
|
+
*/
|
|
27
|
+
function timeToMinutes(timeStr) {
|
|
28
|
+
const [h, m] = timeStr.split(':').map(Number);
|
|
29
|
+
return h * 60 + m;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format minutes since midnight back to HH:MM.
|
|
34
|
+
* @param {number} minutes
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function minutesToTime(minutes) {
|
|
38
|
+
const h = Math.floor(minutes / 60);
|
|
39
|
+
const m = minutes % 60;
|
|
40
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check availability for a given date and time window.
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
47
|
+
* @param {object} opts.params
|
|
48
|
+
* @param {string} opts.params.date - ISO date to check (required)
|
|
49
|
+
* @param {string} opts.params.startTime - Window start time in HH:MM (required)
|
|
50
|
+
* @param {string} opts.params.endTime - Window end time in HH:MM (required)
|
|
51
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
52
|
+
*/
|
|
53
|
+
export async function checkAvailability({ page, params }) {
|
|
54
|
+
// Validate required params
|
|
55
|
+
if (!params.date) {
|
|
56
|
+
return new ErrorResponse(
|
|
57
|
+
'The "date" parameter is required to check availability.',
|
|
58
|
+
['Provide a date: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!params.startTime) {
|
|
62
|
+
return new ErrorResponse(
|
|
63
|
+
'The "startTime" parameter is required to check availability.',
|
|
64
|
+
['Provide a start time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!params.endTime) {
|
|
68
|
+
return new ErrorResponse(
|
|
69
|
+
'The "endTime" parameter is required to check availability.',
|
|
70
|
+
['Provide an end time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate startTime < endTime
|
|
75
|
+
const windowStart = timeToMinutes(params.startTime);
|
|
76
|
+
const windowEnd = timeToMinutes(params.endTime);
|
|
77
|
+
if (windowStart >= windowEnd) {
|
|
78
|
+
return new ErrorResponse(
|
|
79
|
+
`startTime (${params.startTime}) must be earlier than endTime (${params.endTime}).`,
|
|
80
|
+
['Ensure the start time is before the end time, e.g. startTime: "09:00", endTime: "17:00"']
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Precondition: must be on Google Calendar
|
|
85
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
86
|
+
if (!pre.met) {
|
|
87
|
+
return new ErrorResponse(pre.error, [
|
|
88
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// T1: Navigate to the day view for the specified date
|
|
93
|
+
const path = buildViewPath('day', params.date);
|
|
94
|
+
await calendarNavigate(page, path);
|
|
95
|
+
logger.debug(`checkAvailability: navigated to day view for ${params.date}`);
|
|
96
|
+
|
|
97
|
+
// Wait for event chips (or empty day)
|
|
98
|
+
let events = [];
|
|
99
|
+
try {
|
|
100
|
+
await waitForCalendar(page, EVENT_CHIP);
|
|
101
|
+
events = await extractVisibleEvents(page, 100);
|
|
102
|
+
} catch {
|
|
103
|
+
// No events — the entire window is free
|
|
104
|
+
logger.debug('checkAvailability: no events found, entire window is free');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Filter events that overlap with the requested time window
|
|
108
|
+
const busySlots = [];
|
|
109
|
+
for (const event of events) {
|
|
110
|
+
if (event.allDay) {
|
|
111
|
+
// All-day events occupy the full day but don't block time slots
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse event times (best-effort from extracted data)
|
|
116
|
+
if (event.startTime && event.endTime) {
|
|
117
|
+
const eventStart = timeToMinutes(event.startTime);
|
|
118
|
+
const eventEnd = timeToMinutes(event.endTime);
|
|
119
|
+
|
|
120
|
+
// Check overlap: event overlaps window if event starts before window ends
|
|
121
|
+
// AND event ends after window starts
|
|
122
|
+
if (eventStart < windowEnd && eventEnd > windowStart) {
|
|
123
|
+
busySlots.push({
|
|
124
|
+
title: event.title,
|
|
125
|
+
startTime: event.startTime,
|
|
126
|
+
endTime: event.endTime
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Compute free slots within the window
|
|
133
|
+
const freeSlots = [];
|
|
134
|
+
// Sort busy slots by start time
|
|
135
|
+
busySlots.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
|
136
|
+
|
|
137
|
+
let cursor = windowStart;
|
|
138
|
+
for (const busy of busySlots) {
|
|
139
|
+
const busyStart = timeToMinutes(busy.startTime);
|
|
140
|
+
const busyEnd = timeToMinutes(busy.endTime);
|
|
141
|
+
|
|
142
|
+
if (cursor < busyStart) {
|
|
143
|
+
freeSlots.push({
|
|
144
|
+
startTime: minutesToTime(cursor),
|
|
145
|
+
endTime: minutesToTime(Math.min(busyStart, windowEnd)),
|
|
146
|
+
durationMinutes: Math.min(busyStart, windowEnd) - cursor
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
cursor = Math.max(cursor, busyEnd);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Final free slot after all busy periods
|
|
153
|
+
if (cursor < windowEnd) {
|
|
154
|
+
freeSlots.push({
|
|
155
|
+
startTime: minutesToTime(cursor),
|
|
156
|
+
endTime: minutesToTime(windowEnd),
|
|
157
|
+
durationMinutes: windowEnd - cursor
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const isFree = busySlots.length === 0;
|
|
162
|
+
const summary = isFree
|
|
163
|
+
? `${params.date} from ${params.startTime} to ${params.endTime} is completely free.`
|
|
164
|
+
: `${params.date} from ${params.startTime} to ${params.endTime} has ${busySlots.length} conflicting event(s) and ${freeSlots.length} free slot(s).`;
|
|
165
|
+
|
|
166
|
+
return new GCalActionResponse(
|
|
167
|
+
{
|
|
168
|
+
date: params.date,
|
|
169
|
+
windowStart: params.startTime,
|
|
170
|
+
windowEnd: params.endTime,
|
|
171
|
+
isFree,
|
|
172
|
+
busySlots,
|
|
173
|
+
freeSlots,
|
|
174
|
+
conflictCount: busySlots.length
|
|
175
|
+
},
|
|
176
|
+
summary,
|
|
177
|
+
isFree
|
|
178
|
+
? [`Use create_event({ date: "${params.date}", startTime: "${params.startTime}" }) to book this slot`]
|
|
179
|
+
: [
|
|
180
|
+
'Use create_event to book one of the free slots',
|
|
181
|
+
'Use check_availability with a different time range to find open slots',
|
|
182
|
+
'Use list_events to see all events on this date'
|
|
183
|
+
]
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-event.js — Create a new calendar event with title, time, location,
|
|
3
|
+
* description, and attendees.
|
|
4
|
+
*
|
|
5
|
+
* Tier usage:
|
|
6
|
+
* T2: 'c' keyboard shortcut to open creation form
|
|
7
|
+
* T3: ARIA dialog / input targeting for form filling
|
|
8
|
+
* T4: SAVE_BUTTON selector for conditional save
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
12
|
+
import logger from '../../../core/logger.js';
|
|
13
|
+
import {
|
|
14
|
+
checkPrecondition,
|
|
15
|
+
checkKeyboardShortcuts,
|
|
16
|
+
waitForCalendar,
|
|
17
|
+
GCalActionResponse
|
|
18
|
+
} from '../helpers.js';
|
|
19
|
+
import { SAVE_BUTTON } from '../selectors.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a new calendar event, optionally saving it immediately.
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
25
|
+
* @param {object} opts.params
|
|
26
|
+
* @param {string} opts.params.title - Event title (required)
|
|
27
|
+
* @param {string} [opts.params.date] - Event date in ISO format
|
|
28
|
+
* @param {string} [opts.params.startTime] - Start time in HH:MM format
|
|
29
|
+
* @param {string} [opts.params.endTime] - End time in HH:MM format
|
|
30
|
+
* @param {boolean} [opts.params.allDay=false] - Create an all-day event
|
|
31
|
+
* @param {string} [opts.params.location] - Event location
|
|
32
|
+
* @param {string} [opts.params.description] - Event description/notes
|
|
33
|
+
* @param {string[]} [opts.params.attendees] - Array of attendee email addresses
|
|
34
|
+
* @param {boolean} [opts.params.save=false] - If true, save the event (FR-015)
|
|
35
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
36
|
+
*/
|
|
37
|
+
export async function createEvent({ page, params }) {
|
|
38
|
+
// Validate required param
|
|
39
|
+
if (!params.title) {
|
|
40
|
+
return new ErrorResponse(
|
|
41
|
+
'The "title" parameter is required to create an event.',
|
|
42
|
+
['Provide a title: create_event({ title: "Team Meeting", date: "2026-04-10", startTime: "10:00" })']
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Precondition: must be on Google Calendar
|
|
47
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
48
|
+
if (!pre.met) {
|
|
49
|
+
return new ErrorResponse(pre.error, [
|
|
50
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// T2: Verify keyboard shortcuts are enabled
|
|
55
|
+
const kb = await checkKeyboardShortcuts(page);
|
|
56
|
+
if (!kb.enabled) {
|
|
57
|
+
return new ErrorResponse(kb.error, [
|
|
58
|
+
'Enable keyboard shortcuts in Google Calendar Settings → Keyboard shortcuts → Enable keyboard shortcuts, then reload Calendar.'
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// FR-023: Close any existing dialog before opening a new one
|
|
63
|
+
const existingDialog = await page.$('div[role="dialog"]');
|
|
64
|
+
if (existingDialog) {
|
|
65
|
+
logger.debug('createEvent: closing existing dialog');
|
|
66
|
+
await page.keyboard.press('Escape');
|
|
67
|
+
await new Promise(r => setTimeout(r, 300));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Ensure nothing is focused that would swallow the keystroke
|
|
71
|
+
await page.evaluate(() => {
|
|
72
|
+
if (document.activeElement && document.activeElement.tagName !== 'BODY') {
|
|
73
|
+
document.activeElement.blur();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// T2: Press 'c' to open create event form
|
|
78
|
+
await page.keyboard.press('c');
|
|
79
|
+
|
|
80
|
+
// Wait for the quick-add dialog or full event form
|
|
81
|
+
try {
|
|
82
|
+
await waitForCalendar(page, 'div[role="dialog"]');
|
|
83
|
+
} catch {
|
|
84
|
+
return new ErrorResponse(
|
|
85
|
+
'Event creation form did not appear. Keyboard shortcut "c" may not have worked.',
|
|
86
|
+
[
|
|
87
|
+
'Ensure keyboard shortcuts are enabled in Calendar Settings',
|
|
88
|
+
'Try clicking the "+" button on the calendar manually'
|
|
89
|
+
]
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// T3: Fill in the title field — target the title input inside the dialog
|
|
94
|
+
const titleInput = await page.$('input[aria-label="Add title"]') ||
|
|
95
|
+
await page.$('input[data-initial-value]') ||
|
|
96
|
+
await page.$('div[role="dialog"] input[type="text"]');
|
|
97
|
+
if (titleInput) {
|
|
98
|
+
await titleInput.click({ clickCount: 3 }); // select all existing text
|
|
99
|
+
await titleInput.type(params.title);
|
|
100
|
+
logger.debug(`createEvent: typed title "${params.title}"`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check if we need to expand to the full form for additional fields
|
|
104
|
+
const hasExtraFields = params.date || params.startTime || params.endTime ||
|
|
105
|
+
params.location || params.description || params.attendees || params.allDay;
|
|
106
|
+
|
|
107
|
+
if (hasExtraFields) {
|
|
108
|
+
// Click "More options" to open full form if in quick-add mode
|
|
109
|
+
const moreOptions = await page.$('button[aria-label="More options"]') ||
|
|
110
|
+
await page.$('[data-moreactions]');
|
|
111
|
+
if (moreOptions) {
|
|
112
|
+
await moreOptions.click();
|
|
113
|
+
logger.debug('createEvent: expanded to full form');
|
|
114
|
+
await new Promise(r => setTimeout(r, 500));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// T3: Fill date field if provided
|
|
119
|
+
if (params.date) {
|
|
120
|
+
const dateInput = await page.$('input[aria-label*="date" i]') ||
|
|
121
|
+
await page.$('input[data-date]');
|
|
122
|
+
if (dateInput) {
|
|
123
|
+
await dateInput.click({ clickCount: 3 });
|
|
124
|
+
await dateInput.type(params.date);
|
|
125
|
+
await page.keyboard.press('Tab');
|
|
126
|
+
logger.debug(`createEvent: set date "${params.date}"`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// T3: Toggle all-day if requested
|
|
131
|
+
if (params.allDay) {
|
|
132
|
+
const allDayCheckbox = await page.$('input[aria-label*="all day" i]') ||
|
|
133
|
+
await page.$('[data-allday] input[type="checkbox"]');
|
|
134
|
+
if (allDayCheckbox) {
|
|
135
|
+
await allDayCheckbox.click();
|
|
136
|
+
logger.debug('createEvent: toggled all-day');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// T3: Fill start time if provided (and not all-day)
|
|
141
|
+
if (params.startTime && !params.allDay) {
|
|
142
|
+
const startInput = await page.$('input[aria-label*="start time" i]') ||
|
|
143
|
+
await page.$('input[aria-label*="Start time" i]');
|
|
144
|
+
if (startInput) {
|
|
145
|
+
await startInput.click({ clickCount: 3 });
|
|
146
|
+
await startInput.type(params.startTime);
|
|
147
|
+
await page.keyboard.press('Tab');
|
|
148
|
+
logger.debug(`createEvent: set startTime "${params.startTime}"`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// T3: Fill end time if provided (and not all-day)
|
|
153
|
+
if (params.endTime && !params.allDay) {
|
|
154
|
+
const endInput = await page.$('input[aria-label*="end time" i]') ||
|
|
155
|
+
await page.$('input[aria-label*="End time" i]');
|
|
156
|
+
if (endInput) {
|
|
157
|
+
await endInput.click({ clickCount: 3 });
|
|
158
|
+
await endInput.type(params.endTime);
|
|
159
|
+
await page.keyboard.press('Tab');
|
|
160
|
+
logger.debug(`createEvent: set endTime "${params.endTime}"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// T3: Fill location if provided
|
|
165
|
+
if (params.location) {
|
|
166
|
+
const locationInput = await page.$('input[aria-label*="location" i]') ||
|
|
167
|
+
await page.$('[data-locationactionpanel] input');
|
|
168
|
+
if (locationInput) {
|
|
169
|
+
await locationInput.click();
|
|
170
|
+
await locationInput.type(params.location);
|
|
171
|
+
await page.keyboard.press('Tab');
|
|
172
|
+
logger.debug(`createEvent: set location "${params.location}"`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// T3: Fill description if provided
|
|
177
|
+
if (params.description) {
|
|
178
|
+
const descInput = await page.$('div[aria-label*="description" i][contenteditable]') ||
|
|
179
|
+
await page.$('textarea[aria-label*="description" i]') ||
|
|
180
|
+
await page.$('[data-description] [contenteditable]');
|
|
181
|
+
if (descInput) {
|
|
182
|
+
await descInput.click();
|
|
183
|
+
await descInput.type(params.description);
|
|
184
|
+
logger.debug('createEvent: set description');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// T3: Add attendees if provided
|
|
189
|
+
if (params.attendees && params.attendees.length > 0) {
|
|
190
|
+
const guestInput = await page.$('input[aria-label*="guest" i]') ||
|
|
191
|
+
await page.$('input[aria-label*="Add guests" i]');
|
|
192
|
+
if (guestInput) {
|
|
193
|
+
for (const email of params.attendees) {
|
|
194
|
+
await guestInput.click();
|
|
195
|
+
await guestInput.type(email);
|
|
196
|
+
await page.keyboard.press('Enter');
|
|
197
|
+
await new Promise(r => setTimeout(r, 200));
|
|
198
|
+
}
|
|
199
|
+
logger.debug(`createEvent: added ${params.attendees.length} attendee(s)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// FR-015: Conditionally save — default is false (leave for review)
|
|
204
|
+
const save = params.save === true;
|
|
205
|
+
if (save) {
|
|
206
|
+
const saveBtn = await page.$(SAVE_BUTTON) ||
|
|
207
|
+
await page.$('button[aria-label="Save"]') ||
|
|
208
|
+
await page.$('div[role="dialog"] button:has-text("Save")');
|
|
209
|
+
if (saveBtn) {
|
|
210
|
+
await saveBtn.click();
|
|
211
|
+
logger.debug('createEvent: clicked Save');
|
|
212
|
+
await new Promise(r => setTimeout(r, 500));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const status = save ? 'saved' : 'draft';
|
|
217
|
+
const summary = save
|
|
218
|
+
? `Event "${params.title}" created and saved.`
|
|
219
|
+
: `Event "${params.title}" form filled. Review and save in Calendar.`;
|
|
220
|
+
|
|
221
|
+
return new GCalActionResponse(
|
|
222
|
+
{
|
|
223
|
+
status,
|
|
224
|
+
title: params.title,
|
|
225
|
+
date: params.date || 'today',
|
|
226
|
+
startTime: params.startTime || null,
|
|
227
|
+
endTime: params.endTime || null,
|
|
228
|
+
allDay: params.allDay || false,
|
|
229
|
+
location: params.location || null,
|
|
230
|
+
attendees: params.attendees || [],
|
|
231
|
+
save
|
|
232
|
+
},
|
|
233
|
+
summary,
|
|
234
|
+
save
|
|
235
|
+
? ['Use list_events to see the updated calendar']
|
|
236
|
+
: ['Review the event form in Calendar and click Save manually', 'Use list_events to return to calendar view']
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delete-event.js — Remove an event from Google Calendar.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T2: Delete/Backspace keyboard shortcut to delete event
|
|
6
|
+
* T3: selectEvent for clicking event chip, ARIA dialog for confirmation
|
|
7
|
+
*
|
|
8
|
+
* FR-022: Precondition check before destructive keyboard action.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
12
|
+
import logger from '../../../core/logger.js';
|
|
13
|
+
import {
|
|
14
|
+
checkPrecondition,
|
|
15
|
+
selectEvent,
|
|
16
|
+
waitForCalendar,
|
|
17
|
+
detectView,
|
|
18
|
+
VIEW,
|
|
19
|
+
GCalActionResponse
|
|
20
|
+
} from '../helpers.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Delete an event by index or event ID.
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
26
|
+
* @param {object} opts.params
|
|
27
|
+
* @param {number} [opts.params.index] - 0-based position in current event list
|
|
28
|
+
* @param {string} [opts.params.id] - Google Calendar event ID
|
|
29
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
30
|
+
*/
|
|
31
|
+
export async function deleteEvent({ page, params }) {
|
|
32
|
+
// Validate: at least one identifier required
|
|
33
|
+
if (params.index == null && params.id == null) {
|
|
34
|
+
return new ErrorResponse(
|
|
35
|
+
'Either index or id is required to delete an event.',
|
|
36
|
+
[
|
|
37
|
+
'Use list_events to see available events and their indices',
|
|
38
|
+
'Use search_events to find a specific event by keyword'
|
|
39
|
+
]
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Precondition: must be on Google Calendar
|
|
44
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
45
|
+
if (!pre.met) {
|
|
46
|
+
return new ErrorResponse(pre.error, [
|
|
47
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// T3: Select (click) the target event to open detail popup
|
|
52
|
+
const sel = await selectEvent(page, { index: params.index, id: params.id });
|
|
53
|
+
if (!sel.selected) {
|
|
54
|
+
return new ErrorResponse(
|
|
55
|
+
sel.error || 'Could not select the event to delete.',
|
|
56
|
+
['Use list_events to refresh the event list and check indices']
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Wait for detail popup
|
|
61
|
+
try {
|
|
62
|
+
await waitForCalendar(page, 'div[role="dialog"]');
|
|
63
|
+
} catch {
|
|
64
|
+
return new ErrorResponse(
|
|
65
|
+
'Event detail popup did not appear after clicking the event.',
|
|
66
|
+
['Try list_events to refresh, then delete_event with a valid index']
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Detect recurring event dialog — if it appears, select "This event"
|
|
71
|
+
const recurringDialog = await page.evaluate(() => {
|
|
72
|
+
const buttons = Array.from(document.querySelectorAll('button, span[role="radio"]'));
|
|
73
|
+
return buttons.some(b => b.textContent?.includes('This event'));
|
|
74
|
+
});
|
|
75
|
+
let recurringNote = null;
|
|
76
|
+
if (recurringDialog) {
|
|
77
|
+
const thisEventBtn = await page.evaluateHandle(() => {
|
|
78
|
+
const elements = Array.from(document.querySelectorAll('button, span[role="radio"], label'));
|
|
79
|
+
return elements.find(el => el.textContent?.includes('This event'));
|
|
80
|
+
});
|
|
81
|
+
if (thisEventBtn && thisEventBtn.asElement()) {
|
|
82
|
+
await thisEventBtn.asElement().click();
|
|
83
|
+
await new Promise(r => setTimeout(r, 300));
|
|
84
|
+
recurringNote = 'Deleted this single occurrence of a recurring event.';
|
|
85
|
+
logger.debug('deleteEvent: selected "This event" for recurring event');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// FR-022: Precondition check — verify we're in the right context
|
|
90
|
+
// before sending destructive keyboard shortcut
|
|
91
|
+
const view = await detectView(page);
|
|
92
|
+
const hasDialog = await page.$('div[role="dialog"]');
|
|
93
|
+
if (!hasDialog && view === VIEW.NOT_CALENDAR) {
|
|
94
|
+
return new ErrorResponse(
|
|
95
|
+
'Cannot delete: lost context. The event dialog is no longer visible.',
|
|
96
|
+
['Use list_events to refresh, then try delete_event again']
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// T2: Click the "Delete event" button or press Delete/Backspace
|
|
101
|
+
const deleteBtn = await page.$('button[aria-label="Delete event"]') ||
|
|
102
|
+
await page.$('button[aria-label*="delete" i]') ||
|
|
103
|
+
await page.$('[data-deletebtn]');
|
|
104
|
+
if (deleteBtn) {
|
|
105
|
+
await deleteBtn.click();
|
|
106
|
+
logger.debug('deleteEvent: clicked Delete button');
|
|
107
|
+
} else {
|
|
108
|
+
// Fallback: use keyboard shortcut
|
|
109
|
+
await page.keyboard.press('Delete');
|
|
110
|
+
logger.debug('deleteEvent: pressed Delete key');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Wait for confirmation or undo toast
|
|
114
|
+
await new Promise(r => setTimeout(r, 500));
|
|
115
|
+
|
|
116
|
+
// Check for confirmation dialog (some events require explicit confirmation)
|
|
117
|
+
const confirmBtn = await page.evaluateHandle(() => {
|
|
118
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
119
|
+
return buttons.find(b =>
|
|
120
|
+
b.textContent?.includes('Delete') ||
|
|
121
|
+
b.textContent?.includes('Remove')
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
if (confirmBtn && confirmBtn.asElement()) {
|
|
125
|
+
await confirmBtn.asElement().click();
|
|
126
|
+
logger.debug('deleteEvent: confirmed deletion');
|
|
127
|
+
await new Promise(r => setTimeout(r, 300));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new GCalActionResponse(
|
|
131
|
+
{
|
|
132
|
+
deleted: true,
|
|
133
|
+
recurringNote
|
|
134
|
+
},
|
|
135
|
+
recurringNote || 'Event deleted successfully.',
|
|
136
|
+
['Use list_events to see the updated calendar']
|
|
137
|
+
);
|
|
138
|
+
}
|