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.
- package/package.json +1 -1
- package/src/actions/click-element.js +9 -6
- package/src/actions/execute-javascript.js +9 -6
- package/src/actions/fetch-page.js +10 -6
- package/src/actions/get-current-html.js +10 -6
- package/src/core/plugin-loader.js +26 -5
- package/src/plugins/_example/index.js +1 -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/index.js +1 -0
- package/src/plugins.json +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-events.js — List visible events from the current Google Calendar view.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T1: calendarNavigate + buildViewPath for date/view navigation
|
|
6
|
+
* T3: extractVisibleEvents for event chip data extraction
|
|
7
|
+
* T4: EVENT_CHIP selector via waitForCalendar
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
11
|
+
import {
|
|
12
|
+
checkPrecondition,
|
|
13
|
+
calendarNavigate,
|
|
14
|
+
buildViewPath,
|
|
15
|
+
waitForCalendar,
|
|
16
|
+
extractVisibleEvents,
|
|
17
|
+
detectView,
|
|
18
|
+
GCalActionResponse
|
|
19
|
+
} from '../helpers.js';
|
|
20
|
+
import { EVENT_CHIP } from '../selectors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List events from the current Calendar view or navigate to a specific date/view.
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
26
|
+
* @param {object} opts.params
|
|
27
|
+
* @param {string} [opts.params.date] - ISO date to navigate to (e.g., '2026-04-10')
|
|
28
|
+
* @param {string} [opts.params.view] - Calendar view: day, week, month, schedule
|
|
29
|
+
* @param {number} [opts.params.limit=25] - Maximum events to return
|
|
30
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
31
|
+
*/
|
|
32
|
+
export async function listEvents({ page, params }) {
|
|
33
|
+
// Precondition: must be on Google Calendar
|
|
34
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
35
|
+
if (!pre.met) {
|
|
36
|
+
return new ErrorResponse(pre.error, [
|
|
37
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const date = params.date || null;
|
|
42
|
+
const view = params.view || null;
|
|
43
|
+
const limit = params.limit || 25;
|
|
44
|
+
|
|
45
|
+
// Validate view param if provided
|
|
46
|
+
const validViews = ['day', 'week', 'month', 'schedule', 'custom'];
|
|
47
|
+
if (view && !validViews.includes(view)) {
|
|
48
|
+
return new ErrorResponse(
|
|
49
|
+
`Invalid view "${view}". Must be one of: ${validViews.join(', ')}.`,
|
|
50
|
+
['Use list_events({ view: "week" }) or list_events({ view: "day", date: "2026-04-10" })']
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// T1: Navigate to date/view if either is provided
|
|
55
|
+
if (date || view) {
|
|
56
|
+
const targetView = view || 'week';
|
|
57
|
+
const path = buildViewPath(targetView, date);
|
|
58
|
+
await calendarNavigate(page, path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Wait for event chips to appear (or timeout gracefully)
|
|
62
|
+
try {
|
|
63
|
+
await waitForCalendar(page, EVENT_CHIP);
|
|
64
|
+
} catch {
|
|
65
|
+
// No events visible — return empty list rather than error
|
|
66
|
+
const currentView = await detectView(page);
|
|
67
|
+
return new GCalActionResponse(
|
|
68
|
+
{ events: [], view: currentView, dateRange: date || 'current', total: 0 },
|
|
69
|
+
'No events found in the current view.',
|
|
70
|
+
[
|
|
71
|
+
'Try a different date or view: list_events({ date: "2026-04-10", view: "week" })',
|
|
72
|
+
'Use search_events to find events by keyword',
|
|
73
|
+
'Use create_event to add a new event'
|
|
74
|
+
]
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// T3+T4: Extract visible event data
|
|
79
|
+
const events = await extractVisibleEvents(page, limit);
|
|
80
|
+
const currentView = await detectView(page);
|
|
81
|
+
|
|
82
|
+
return new GCalActionResponse(
|
|
83
|
+
{
|
|
84
|
+
events,
|
|
85
|
+
view: currentView,
|
|
86
|
+
dateRange: date || 'current',
|
|
87
|
+
total: events.length
|
|
88
|
+
},
|
|
89
|
+
`Found ${events.length} event(s) in ${currentView} view.`,
|
|
90
|
+
[
|
|
91
|
+
'Use read_event({ index: N }) to open a specific event',
|
|
92
|
+
'Use search_events to find events by keyword',
|
|
93
|
+
'Use create_event to add a new event'
|
|
94
|
+
]
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-event.js — Open an event and read its full details.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T3: selectEvent for clicking event chip, ARIA dialog for detail popup
|
|
6
|
+
* T4: EVENT_LOCATION_IN_DETAIL, EVENT_DESCRIPTION_IN_DETAIL,
|
|
7
|
+
* ATTENDEE_ROW, ATTENDEE_RSVP_STATUS selectors for data extraction
|
|
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 {
|
|
19
|
+
EVENT_LOCATION_IN_DETAIL,
|
|
20
|
+
EVENT_DESCRIPTION_IN_DETAIL,
|
|
21
|
+
ATTENDEE_ROW,
|
|
22
|
+
ATTENDEE_RSVP_STATUS
|
|
23
|
+
} from '../selectors.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open and read a specific event by index or event ID.
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
29
|
+
* @param {object} opts.params
|
|
30
|
+
* @param {number} [opts.params.index] - 0-based position in current event list
|
|
31
|
+
* @param {string} [opts.params.id] - Google Calendar event ID
|
|
32
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
33
|
+
*/
|
|
34
|
+
export async function readEvent({ page, params }) {
|
|
35
|
+
// Validate: at least one identifier required
|
|
36
|
+
if (params.index == null && params.id == null) {
|
|
37
|
+
return new ErrorResponse(
|
|
38
|
+
'Either index or id is required to read an event.',
|
|
39
|
+
[
|
|
40
|
+
'Use list_events to see available events and their indices',
|
|
41
|
+
'Use search_events to find a specific event by keyword'
|
|
42
|
+
]
|
|
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
|
+
// FR-023: Close any existing dialog before opening a new one
|
|
55
|
+
const existingDialog = await page.$('div[role="dialog"]');
|
|
56
|
+
if (existingDialog) {
|
|
57
|
+
logger.debug('readEvent: closing existing dialog');
|
|
58
|
+
await page.keyboard.press('Escape');
|
|
59
|
+
await new Promise(r => setTimeout(r, 300));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// T3: Select (click) the target event
|
|
63
|
+
const sel = await selectEvent(page, { index: params.index, id: params.id });
|
|
64
|
+
if (!sel.selected) {
|
|
65
|
+
return new ErrorResponse(
|
|
66
|
+
sel.error || 'Could not select the event.',
|
|
67
|
+
['Use list_events to refresh the event list and check indices']
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wait for detail popup/dialog to appear
|
|
72
|
+
try {
|
|
73
|
+
await waitForCalendar(page, 'div[role="dialog"]');
|
|
74
|
+
} catch {
|
|
75
|
+
return new ErrorResponse(
|
|
76
|
+
'Event detail popup did not appear after clicking the event.',
|
|
77
|
+
[
|
|
78
|
+
'The event chip may not be interactive. Try list_events to refresh.',
|
|
79
|
+
'Try read_event with a different index'
|
|
80
|
+
]
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// T3+T4: Extract event detail fields from the dialog
|
|
85
|
+
const eventDetail = await page.evaluate((selectors) => {
|
|
86
|
+
const dialog = document.querySelector('div[role="dialog"]');
|
|
87
|
+
if (!dialog) return null;
|
|
88
|
+
|
|
89
|
+
// T3: Title from the heading element in the dialog
|
|
90
|
+
const titleEl = dialog.querySelector('span[role="heading"], [data-eventid]') ||
|
|
91
|
+
dialog.querySelector('span');
|
|
92
|
+
const title = titleEl?.textContent?.trim() || '';
|
|
93
|
+
|
|
94
|
+
// T3: Time/date info from aria-label or text content
|
|
95
|
+
const timeEls = dialog.querySelectorAll('div[data-datekey], span[data-datekey]');
|
|
96
|
+
let dateTime = '';
|
|
97
|
+
if (timeEls.length > 0) {
|
|
98
|
+
dateTime = Array.from(timeEls).map(el => el.textContent?.trim()).join(' ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// T4: Location
|
|
102
|
+
const locationEl = dialog.querySelector(selectors.location);
|
|
103
|
+
const location = locationEl?.textContent?.trim() || null;
|
|
104
|
+
|
|
105
|
+
// T4: Description
|
|
106
|
+
const descEl = dialog.querySelector(selectors.description);
|
|
107
|
+
const description = descEl?.textContent?.trim() || null;
|
|
108
|
+
|
|
109
|
+
// T3: Conference link (look for meet or zoom links)
|
|
110
|
+
const links = dialog.querySelectorAll('a[href]');
|
|
111
|
+
let conferenceLink = null;
|
|
112
|
+
for (const link of links) {
|
|
113
|
+
const href = link.getAttribute('href') || '';
|
|
114
|
+
if (href.includes('meet.google.com') || href.includes('zoom.us')) {
|
|
115
|
+
conferenceLink = href;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// T4: Attendees
|
|
121
|
+
const attendeeEls = dialog.querySelectorAll(selectors.attendeeRow);
|
|
122
|
+
const attendees = Array.from(attendeeEls).map(row => {
|
|
123
|
+
const email = row.getAttribute('data-guest-email') || '';
|
|
124
|
+
const name = row.textContent?.trim() || email;
|
|
125
|
+
const rsvpEl = row.querySelector(selectors.rsvpStatus);
|
|
126
|
+
const rsvpStatus = rsvpEl?.getAttribute('data-rsvp') || 'unknown';
|
|
127
|
+
return { email, name, rsvpStatus };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// T3: Organizer (first attendee or dialog context)
|
|
131
|
+
const organizer = attendees.length > 0 ? attendees[0].email : null;
|
|
132
|
+
|
|
133
|
+
// T3: Calendar name from color label
|
|
134
|
+
const calendarEl = dialog.querySelector('[data-calendar-color]');
|
|
135
|
+
const calendarName = calendarEl?.textContent?.trim() || null;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
title,
|
|
139
|
+
dateTime,
|
|
140
|
+
location,
|
|
141
|
+
description,
|
|
142
|
+
conferenceLink,
|
|
143
|
+
attendees,
|
|
144
|
+
organizer,
|
|
145
|
+
calendarName,
|
|
146
|
+
attendeeCount: attendees.length
|
|
147
|
+
};
|
|
148
|
+
}, {
|
|
149
|
+
location: EVENT_LOCATION_IN_DETAIL,
|
|
150
|
+
description: EVENT_DESCRIPTION_IN_DETAIL,
|
|
151
|
+
attendeeRow: ATTENDEE_ROW,
|
|
152
|
+
rsvpStatus: ATTENDEE_RSVP_STATUS
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!eventDetail) {
|
|
156
|
+
return new ErrorResponse(
|
|
157
|
+
'Could not extract event details from the dialog.',
|
|
158
|
+
['Try closing the dialog (press Escape) and re-opening with read_event']
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
logger.debug(`readEvent: extracted "${eventDetail.title}"`);
|
|
163
|
+
|
|
164
|
+
return new GCalActionResponse(
|
|
165
|
+
eventDetail,
|
|
166
|
+
`Event: ${eventDetail.title}${eventDetail.dateTime ? ` — ${eventDetail.dateTime}` : ''}`,
|
|
167
|
+
[
|
|
168
|
+
'Use edit_event to modify this event',
|
|
169
|
+
'Use rsvp_event to respond to this invitation',
|
|
170
|
+
'Use delete_event to remove this event',
|
|
171
|
+
'Press Escape or use list_events to return to the calendar view'
|
|
172
|
+
]
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rsvp-event.js — Respond to a calendar invitation (accept, decline, tentative).
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T3: selectEvent for clicking event chip, ARIA dialog for detail popup
|
|
6
|
+
* T4: RSVP_YES_BUTTON, RSVP_NO_BUTTON, RSVP_MAYBE_BUTTON selectors
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
10
|
+
import logger from '../../../core/logger.js';
|
|
11
|
+
import {
|
|
12
|
+
checkPrecondition,
|
|
13
|
+
selectEvent,
|
|
14
|
+
waitForCalendar,
|
|
15
|
+
GCalActionResponse
|
|
16
|
+
} from '../helpers.js';
|
|
17
|
+
import {
|
|
18
|
+
RSVP_YES_BUTTON,
|
|
19
|
+
RSVP_NO_BUTTON,
|
|
20
|
+
RSVP_MAYBE_BUTTON
|
|
21
|
+
} from '../selectors.js';
|
|
22
|
+
|
|
23
|
+
/** Map response names to selector keys. */
|
|
24
|
+
const RSVP_MAP = {
|
|
25
|
+
accept: RSVP_YES_BUTTON,
|
|
26
|
+
decline: RSVP_NO_BUTTON,
|
|
27
|
+
tentative: RSVP_MAYBE_BUTTON
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* RSVP to a calendar invitation.
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
34
|
+
* @param {object} opts.params
|
|
35
|
+
* @param {number} [opts.params.index] - 0-based position in current event list
|
|
36
|
+
* @param {string} [opts.params.id] - Google Calendar event ID
|
|
37
|
+
* @param {string} opts.params.response - "accept", "decline", or "tentative" (required)
|
|
38
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
39
|
+
*/
|
|
40
|
+
export async function rsvpEvent({ page, params }) {
|
|
41
|
+
// Validate: at least one identifier required
|
|
42
|
+
if (params.index == null && params.id == null) {
|
|
43
|
+
return new ErrorResponse(
|
|
44
|
+
'Either index or id is required to RSVP to an event.',
|
|
45
|
+
[
|
|
46
|
+
'Use list_events to see available events and their indices',
|
|
47
|
+
'Use search_events to find a specific event by keyword'
|
|
48
|
+
]
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate response value
|
|
53
|
+
const response = (params.response || '').toLowerCase();
|
|
54
|
+
if (!RSVP_MAP[response]) {
|
|
55
|
+
return new ErrorResponse(
|
|
56
|
+
`Invalid RSVP response "${params.response}". Must be one of: accept, decline, tentative.`,
|
|
57
|
+
['Use rsvp_event({ index: 0, response: "accept" })']
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Precondition: must be on Google Calendar
|
|
62
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
63
|
+
if (!pre.met) {
|
|
64
|
+
return new ErrorResponse(pre.error, [
|
|
65
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// T3: Select (click) the target event to open detail popup
|
|
70
|
+
const sel = await selectEvent(page, { index: params.index, id: params.id });
|
|
71
|
+
if (!sel.selected) {
|
|
72
|
+
return new ErrorResponse(
|
|
73
|
+
sel.error || 'Could not select the event to RSVP.',
|
|
74
|
+
['Use list_events to refresh the event list and check indices']
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wait for detail popup
|
|
79
|
+
try {
|
|
80
|
+
await waitForCalendar(page, 'div[role="dialog"]');
|
|
81
|
+
} catch {
|
|
82
|
+
return new ErrorResponse(
|
|
83
|
+
'Event detail popup did not appear after clicking the event.',
|
|
84
|
+
['Try list_events to refresh, then rsvp_event with a valid index']
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Detect if the user is the organizer (organizers cannot RSVP to their own events)
|
|
89
|
+
const isOrganizer = await page.evaluate(() => {
|
|
90
|
+
const dialog = document.querySelector('div[role="dialog"]');
|
|
91
|
+
if (!dialog) return false;
|
|
92
|
+
// Organizer view typically shows "Edit event" but not RSVP buttons
|
|
93
|
+
const editBtn = dialog.querySelector('button[aria-label="Edit event"]');
|
|
94
|
+
const rsvpBtn = dialog.querySelector('[data-response]');
|
|
95
|
+
return editBtn && !rsvpBtn;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (isOrganizer) {
|
|
99
|
+
return new ErrorResponse(
|
|
100
|
+
'You are the organizer of this event. Organizers cannot RSVP to their own events.',
|
|
101
|
+
[
|
|
102
|
+
'Use edit_event to modify the event instead',
|
|
103
|
+
'Use delete_event to cancel the event'
|
|
104
|
+
]
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// T4: Click the appropriate RSVP button
|
|
109
|
+
const rsvpSelector = RSVP_MAP[response];
|
|
110
|
+
const rsvpBtn = await page.$(rsvpSelector);
|
|
111
|
+
if (!rsvpBtn) {
|
|
112
|
+
// Fallback: look for RSVP button by text content
|
|
113
|
+
const fallbackBtn = await page.evaluateHandle((resp) => {
|
|
114
|
+
const buttons = Array.from(document.querySelectorAll('button, div[role="button"]'));
|
|
115
|
+
const labels = { accept: 'Yes', decline: 'No', tentative: 'Maybe' };
|
|
116
|
+
return buttons.find(b => b.textContent?.trim() === labels[resp]);
|
|
117
|
+
}, response);
|
|
118
|
+
|
|
119
|
+
if (fallbackBtn && fallbackBtn.asElement()) {
|
|
120
|
+
await fallbackBtn.asElement().click();
|
|
121
|
+
logger.debug(`rsvpEvent: clicked RSVP "${response}" via text fallback`);
|
|
122
|
+
} else {
|
|
123
|
+
return new ErrorResponse(
|
|
124
|
+
`Could not find the RSVP "${response}" button. This event may not have RSVP options.`,
|
|
125
|
+
[
|
|
126
|
+
'This event may not be an invitation — you can only RSVP to events you were invited to',
|
|
127
|
+
'Use read_event to inspect the event details'
|
|
128
|
+
]
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
await rsvpBtn.click();
|
|
133
|
+
logger.debug(`rsvpEvent: clicked RSVP "${response}" via selector`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await new Promise(r => setTimeout(r, 300));
|
|
137
|
+
|
|
138
|
+
return new GCalActionResponse(
|
|
139
|
+
{
|
|
140
|
+
response,
|
|
141
|
+
rsvpSent: true
|
|
142
|
+
},
|
|
143
|
+
`RSVP "${response}" sent successfully.`,
|
|
144
|
+
[
|
|
145
|
+
'Use list_events to return to the calendar view',
|
|
146
|
+
'Use read_event to verify the RSVP status'
|
|
147
|
+
]
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-events.js — Search Google Calendar for events matching a keyword query.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T2: '/' keyboard shortcut to focus search
|
|
6
|
+
* T3: extractVisibleEvents for result extraction
|
|
7
|
+
* T4: EVENT_CHIP selector for result detection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
11
|
+
import logger from '../../../core/logger.js';
|
|
12
|
+
import {
|
|
13
|
+
checkPrecondition,
|
|
14
|
+
waitForCalendar,
|
|
15
|
+
extractVisibleEvents,
|
|
16
|
+
GCalActionResponse
|
|
17
|
+
} from '../helpers.js';
|
|
18
|
+
import { EVENT_CHIP } from '../selectors.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Search events by query string using Calendar's search UI.
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
24
|
+
* @param {object} opts.params
|
|
25
|
+
* @param {string} opts.params.query - Search keywords (required)
|
|
26
|
+
* @param {number} [opts.params.limit=25] - Maximum results to return
|
|
27
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
28
|
+
*/
|
|
29
|
+
export async function searchEvents({ page, params }) {
|
|
30
|
+
// Validate query
|
|
31
|
+
if (!params.query || !params.query.trim()) {
|
|
32
|
+
return new ErrorResponse(
|
|
33
|
+
'Search query is required.',
|
|
34
|
+
[
|
|
35
|
+
'Provide a query: search_events({ query: "team meeting" })',
|
|
36
|
+
'Use list_events to browse the calendar without searching'
|
|
37
|
+
]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Precondition: must be on Google Calendar
|
|
42
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
43
|
+
if (!pre.met) {
|
|
44
|
+
return new ErrorResponse(pre.error, [
|
|
45
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const query = params.query.trim();
|
|
50
|
+
const limit = params.limit || 25;
|
|
51
|
+
|
|
52
|
+
// Ensure nothing is focused that would swallow the keystroke
|
|
53
|
+
await page.evaluate(() => {
|
|
54
|
+
if (document.activeElement && document.activeElement.tagName !== 'BODY') {
|
|
55
|
+
document.activeElement.blur();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// T2: Press '/' to focus the search box
|
|
60
|
+
await page.keyboard.press('/');
|
|
61
|
+
await new Promise(r => setTimeout(r, 300));
|
|
62
|
+
|
|
63
|
+
// T3: Type query into the search input
|
|
64
|
+
const searchInput = await page.$('input[aria-label="Search"]') ||
|
|
65
|
+
await page.$('input[aria-label*="search" i]') ||
|
|
66
|
+
await page.$('input[type="text"][role="searchbox"]');
|
|
67
|
+
if (searchInput) {
|
|
68
|
+
await searchInput.click({ clickCount: 3 }); // clear existing text
|
|
69
|
+
await searchInput.type(query);
|
|
70
|
+
logger.debug(`searchEvents: typed query "${query}"`);
|
|
71
|
+
} else {
|
|
72
|
+
// Fallback: type directly if the search field is already focused by '/'
|
|
73
|
+
await page.keyboard.type(query);
|
|
74
|
+
logger.debug(`searchEvents: typed query via keyboard "${query}"`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Press Enter to execute search
|
|
78
|
+
await page.keyboard.press('Enter');
|
|
79
|
+
|
|
80
|
+
// Wait for search results to appear
|
|
81
|
+
let hasResults = true;
|
|
82
|
+
try {
|
|
83
|
+
await waitForCalendar(page, EVENT_CHIP);
|
|
84
|
+
} catch {
|
|
85
|
+
// Check for "no results" state
|
|
86
|
+
const noResults = await page.evaluate(() => {
|
|
87
|
+
const body = document.body?.textContent || '';
|
|
88
|
+
return body.includes('No results') || body.includes('No events found');
|
|
89
|
+
});
|
|
90
|
+
if (noResults) {
|
|
91
|
+
hasResults = false;
|
|
92
|
+
} else {
|
|
93
|
+
// Re-check — page may still be loading
|
|
94
|
+
hasResults = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!hasResults) {
|
|
99
|
+
return new GCalActionResponse(
|
|
100
|
+
{ events: [], query, resultCount: 0 },
|
|
101
|
+
`No events found for "${query}".`,
|
|
102
|
+
[
|
|
103
|
+
'Try a different or broader search query',
|
|
104
|
+
'Use list_events to browse the calendar instead'
|
|
105
|
+
]
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// T3+T4: Extract search results
|
|
110
|
+
const events = await extractVisibleEvents(page, limit);
|
|
111
|
+
|
|
112
|
+
return new GCalActionResponse(
|
|
113
|
+
{ events, query, resultCount: events.length },
|
|
114
|
+
`Found ${events.length} result(s) for "${query}".`,
|
|
115
|
+
[
|
|
116
|
+
'Use read_event({ index: N }) to open a specific result',
|
|
117
|
+
'Use list_events to return to the calendar view',
|
|
118
|
+
'Refine your search with more specific keywords'
|
|
119
|
+
]
|
|
120
|
+
);
|
|
121
|
+
}
|