mcpbrowser 0.3.35 → 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.
@@ -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
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * edit-event.js — Modify an existing event's fields.
3
+ *
4
+ * Tier usage:
5
+ * T3: selectEvent for clicking event chip, ARIA dialog for edit form,
6
+ * input targeting for field updates
7
+ * T4: SAVE_BUTTON selector for conditional save
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import logger from '../../../core/logger.js';
12
+ import {
13
+ checkPrecondition,
14
+ selectEvent,
15
+ waitForCalendar,
16
+ GCalActionResponse
17
+ } from '../helpers.js';
18
+ import { SAVE_BUTTON } from '../selectors.js';
19
+
20
+ /**
21
+ * Edit an existing event by index or event ID.
22
+ * @param {object} opts
23
+ * @param {import('puppeteer-core').Page} opts.page
24
+ * @param {object} opts.params
25
+ * @param {number} [opts.params.index] - 0-based position in current event list
26
+ * @param {string} [opts.params.id] - Google Calendar event ID
27
+ * @param {string} [opts.params.title] - New title
28
+ * @param {string} [opts.params.date] - New date (ISO format)
29
+ * @param {string} [opts.params.startTime] - New start time (HH:MM)
30
+ * @param {string} [opts.params.endTime] - New end time (HH:MM)
31
+ * @param {string} [opts.params.location] - New location
32
+ * @param {string} [opts.params.description] - New description
33
+ * @param {string[]} [opts.params.attendees] - New attendee list
34
+ * @param {boolean} [opts.params.allDay] - Toggle all-day
35
+ * @param {boolean} [opts.params.save=false] - If true, save changes (FR-015)
36
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
37
+ */
38
+ export async function editEvent({ page, params }) {
39
+ // Validate: at least one identifier required
40
+ if (params.index == null && params.id == null) {
41
+ return new ErrorResponse(
42
+ 'Either index or id is required to edit an event.',
43
+ [
44
+ 'Use list_events to see available events and their indices',
45
+ 'Use search_events to find a specific event by keyword'
46
+ ]
47
+ );
48
+ }
49
+
50
+ // Precondition: must be on Google Calendar
51
+ const pre = await checkPrecondition(page, 'on_calendar');
52
+ if (!pre.met) {
53
+ return new ErrorResponse(pre.error, [
54
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
55
+ ]);
56
+ }
57
+
58
+ // T3: Select (click) the target event to open detail popup
59
+ const sel = await selectEvent(page, { index: params.index, id: params.id });
60
+ if (!sel.selected) {
61
+ return new ErrorResponse(
62
+ sel.error || 'Could not select the event to edit.',
63
+ ['Use list_events to refresh the event list and check indices']
64
+ );
65
+ }
66
+
67
+ // Wait for detail popup
68
+ try {
69
+ await waitForCalendar(page, 'div[role="dialog"]');
70
+ } catch {
71
+ return new ErrorResponse(
72
+ 'Event detail popup did not appear after clicking the event.',
73
+ ['Try list_events to refresh, then edit_event with a valid index']
74
+ );
75
+ }
76
+
77
+ // Detect recurring event dialog — if it appears, select "This event"
78
+ const recurringDialog = await page.evaluate(() => {
79
+ const buttons = Array.from(document.querySelectorAll('button, span[role="radio"]'));
80
+ return buttons.some(b => b.textContent?.includes('This event'));
81
+ });
82
+ let recurringNote = null;
83
+ if (recurringDialog) {
84
+ const thisEventBtn = await page.evaluateHandle(() => {
85
+ const elements = Array.from(document.querySelectorAll('button, span[role="radio"], label'));
86
+ return elements.find(el => el.textContent?.includes('This event'));
87
+ });
88
+ if (thisEventBtn) {
89
+ await thisEventBtn.asElement()?.click();
90
+ await new Promise(r => setTimeout(r, 300));
91
+ recurringNote = 'Editing this single occurrence of a recurring event.';
92
+ logger.debug('editEvent: selected "This event" for recurring event');
93
+ }
94
+ }
95
+
96
+ // Click \"Edit event\" pencil icon to open the edit form
97
+ const editBtn = await page.$('button[aria-label="Edit event"]') ||
98
+ await page.$('button[aria-label*="edit" i]') ||
99
+ await page.$('[data-editbtn]');
100
+ if (editBtn) {
101
+ await editBtn.click();
102
+ logger.debug('editEvent: clicked Edit button');
103
+ await new Promise(r => setTimeout(r, 500));
104
+ }
105
+
106
+ // Wait for the edit form to load
107
+ try {
108
+ await waitForCalendar(page, 'input[aria-label="Add title"], input[data-initial-value]');
109
+ } catch {
110
+ return new ErrorResponse(
111
+ 'Edit form did not appear. The event may not be editable.',
112
+ ['You may not have edit permissions for this event', 'Try reading the event first with read_event']
113
+ );
114
+ }
115
+
116
+ // T3: Update only the provided fields
117
+ const fieldsUpdated = [];
118
+
119
+ if (params.title) {
120
+ const titleInput = await page.$('input[aria-label="Add title"]') ||
121
+ await page.$('input[data-initial-value]');
122
+ if (titleInput) {
123
+ await titleInput.click({ clickCount: 3 });
124
+ await titleInput.type(params.title);
125
+ fieldsUpdated.push('title');
126
+ logger.debug(`editEvent: updated title to "${params.title}"`);
127
+ }
128
+ }
129
+
130
+ if (params.date) {
131
+ const dateInput = await page.$('input[aria-label*="date" i]') ||
132
+ await page.$('input[data-date]');
133
+ if (dateInput) {
134
+ await dateInput.click({ clickCount: 3 });
135
+ await dateInput.type(params.date);
136
+ await page.keyboard.press('Tab');
137
+ fieldsUpdated.push('date');
138
+ logger.debug(`editEvent: updated date to "${params.date}"`);
139
+ }
140
+ }
141
+
142
+ if (params.allDay !== undefined) {
143
+ const allDayCheckbox = await page.$('input[aria-label*="all day" i]') ||
144
+ await page.$('[data-allday] input[type="checkbox"]');
145
+ if (allDayCheckbox) {
146
+ await allDayCheckbox.click();
147
+ fieldsUpdated.push('allDay');
148
+ logger.debug('editEvent: toggled all-day');
149
+ }
150
+ }
151
+
152
+ if (params.startTime) {
153
+ const startInput = await page.$('input[aria-label*="start time" i]') ||
154
+ await page.$('input[aria-label*="Start time" i]');
155
+ if (startInput) {
156
+ await startInput.click({ clickCount: 3 });
157
+ await startInput.type(params.startTime);
158
+ await page.keyboard.press('Tab');
159
+ fieldsUpdated.push('startTime');
160
+ logger.debug(`editEvent: updated startTime to "${params.startTime}"`);
161
+ }
162
+ }
163
+
164
+ if (params.endTime) {
165
+ const endInput = await page.$('input[aria-label*="end time" i]') ||
166
+ await page.$('input[aria-label*="End time" i]');
167
+ if (endInput) {
168
+ await endInput.click({ clickCount: 3 });
169
+ await endInput.type(params.endTime);
170
+ await page.keyboard.press('Tab');
171
+ fieldsUpdated.push('endTime');
172
+ logger.debug(`editEvent: updated endTime to "${params.endTime}"`);
173
+ }
174
+ }
175
+
176
+ if (params.location) {
177
+ const locationInput = await page.$('input[aria-label*="location" i]') ||
178
+ await page.$('[data-locationactionpanel] input');
179
+ if (locationInput) {
180
+ await locationInput.click({ clickCount: 3 });
181
+ await locationInput.type(params.location);
182
+ await page.keyboard.press('Tab');
183
+ fieldsUpdated.push('location');
184
+ logger.debug(`editEvent: updated location to "${params.location}"`);
185
+ }
186
+ }
187
+
188
+ if (params.description) {
189
+ const descInput = await page.$('div[aria-label*="description" i][contenteditable]') ||
190
+ await page.$('textarea[aria-label*="description" i]') ||
191
+ await page.$('[data-description] [contenteditable]');
192
+ if (descInput) {
193
+ await descInput.click();
194
+ // Select all existing text and replace
195
+ await page.keyboard.down('Control');
196
+ await page.keyboard.press('a');
197
+ await page.keyboard.up('Control');
198
+ await descInput.type(params.description);
199
+ fieldsUpdated.push('description');
200
+ logger.debug('editEvent: updated description');
201
+ }
202
+ }
203
+
204
+ if (params.attendees && params.attendees.length > 0) {
205
+ const guestInput = await page.$('input[aria-label*="guest" i]') ||
206
+ await page.$('input[aria-label*="Add guests" i]');
207
+ if (guestInput) {
208
+ for (const email of params.attendees) {
209
+ await guestInput.click();
210
+ await guestInput.type(email);
211
+ await page.keyboard.press('Enter');
212
+ await new Promise(r => setTimeout(r, 200));
213
+ }
214
+ fieldsUpdated.push('attendees');
215
+ logger.debug(`editEvent: updated attendees (${params.attendees.length} added)`);
216
+ }
217
+ }
218
+
219
+ // FR-015: Conditionally save — default is false
220
+ const save = params.save === true;
221
+ if (save) {
222
+ const saveBtn = await page.$(SAVE_BUTTON) ||
223
+ await page.$('button[aria-label="Save"]');
224
+ if (saveBtn) {
225
+ await saveBtn.click();
226
+ logger.debug('editEvent: clicked Save');
227
+ await new Promise(r => setTimeout(r, 500));
228
+ }
229
+ }
230
+
231
+ return new GCalActionResponse(
232
+ {
233
+ fieldsUpdated,
234
+ saved: save,
235
+ recurringNote
236
+ },
237
+ save
238
+ ? `Event updated and saved. Fields changed: ${fieldsUpdated.join(', ') || 'none'}.`
239
+ : `Event form updated (${fieldsUpdated.join(', ') || 'none'}). Review and save in Calendar.`,
240
+ save
241
+ ? ['Use list_events to see the updated calendar']
242
+ : ['Review changes and click Save manually in Calendar', 'Use list_events to return to calendar view']
243
+ );
244
+ }