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,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
+ }