@commandgarden/cli 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +0 -0
- package/node_modules/@commandgarden/app/dist/client/assets/{Slides-D3xcxd9t.js → Slides-BvMlkL-3.js} +5 -15
- package/node_modules/@commandgarden/app/dist/client/assets/index-BD8reQ8T.js +496 -0
- package/node_modules/@commandgarden/app/dist/client/assets/{index-DbqYLIqy.css → index-wgb9-PIF.css} +1 -1
- package/node_modules/@commandgarden/app/dist/client/index.html +19 -19
- package/node_modules/@commandgarden/app/dist/server/daemon-client.test.js +47 -0
- package/node_modules/@commandgarden/app/dist/server/journal/journal.service.d.ts +1 -0
- package/node_modules/@commandgarden/app/dist/server/journal/journal.service.js +25 -6
- package/node_modules/@commandgarden/app/dist/server/journal/journal.types.d.ts +26 -0
- package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.d.ts +10 -4
- package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.js +36 -29
- package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.d.ts +10 -1
- package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.js +62 -8
- package/node_modules/@commandgarden/app/dist/server/routes/journal.d.ts +12 -0
- package/node_modules/@commandgarden/app/dist/server/routes/journal.js +87 -5
- package/node_modules/@commandgarden/app/dist/server/routes/routes.test.js +137 -0
- package/node_modules/@commandgarden/app/dist/server/store.d.ts +27 -0
- package/node_modules/@commandgarden/app/dist/server/store.js +130 -39
- package/node_modules/@commandgarden/app/dist/server/store.test.js +59 -0
- package/node_modules/@commandgarden/app/package.json +42 -42
- package/node_modules/@commandgarden/chrome/dist/content-script.js.map +1 -1
- package/node_modules/@commandgarden/chrome/dist/fetch-interceptor.js +44 -0
- package/node_modules/@commandgarden/chrome/dist/manifest.json +26 -26
- package/node_modules/@commandgarden/chrome/dist/popup.html +218 -218
- package/node_modules/@commandgarden/chrome/dist/popup.js.map +1 -1
- package/node_modules/@commandgarden/chrome/dist/service-worker.js +14 -4
- package/node_modules/@commandgarden/chrome/dist/service-worker.js.map +2 -2
- package/node_modules/@commandgarden/chrome/package.json +26 -26
- package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.eval.js +100 -100
- package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.yaml +70 -70
- package/node_modules/@commandgarden/daemon/connectors/gcs-kb-content.yaml +61 -58
- package/node_modules/@commandgarden/daemon/connectors/gcs-kb-pages.yaml +54 -47
- package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.eval.js +115 -115
- package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.yaml +54 -54
- package/node_modules/@commandgarden/daemon/connectors/lib/msal-token.js +20 -20
- package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.eval.js +204 -198
- package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.yaml +42 -42
- package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.eval.js +94 -94
- package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.yaml +34 -34
- package/node_modules/@commandgarden/daemon/connectors/socket-security-news.yaml +63 -63
- package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.eval.js +437 -437
- package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.yaml +53 -53
- package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.eval.js +547 -547
- package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.yaml +48 -48
- package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.eval.js +52 -52
- package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.yaml +42 -42
- package/node_modules/@commandgarden/daemon/connectors/timetracking-report.eval.js +81 -81
- package/node_modules/@commandgarden/daemon/connectors/timetracking-report.yaml +52 -52
- package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.eval.js +27 -27
- package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.yaml +38 -38
- package/node_modules/@commandgarden/daemon/connectors/tokenmaster-clients-list.yaml +55 -55
- package/node_modules/@commandgarden/daemon/dist/audit-store.js +8 -8
- package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts +24 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts.map +1 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.js +150 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.js.map +1 -0
- package/node_modules/@commandgarden/daemon/package.json +32 -32
- package/node_modules/@commandgarden/shared/dist/expression/filters.d.ts.map +1 -1
- package/node_modules/@commandgarden/shared/dist/expression/filters.js +2 -0
- package/node_modules/@commandgarden/shared/dist/expression/filters.js.map +1 -1
- package/node_modules/@commandgarden/shared/package.json +32 -32
- package/package.json +58 -58
- package/node_modules/@commandgarden/app/dist/client/assets/index-_jNYPoLF.js +0 -461
|
@@ -1,547 +1,547 @@
|
|
|
1
|
-
// Runs in page context via js_evaluate step.
|
|
2
|
-
// Drives the Outlook Scheduling Assistant to fetch free/busy timelines for
|
|
3
|
-
// multiple meeting rooms on a single day. Rooms are added sequentially so we
|
|
4
|
-
// can reliably map each scheduleId back to the input room name.
|
|
5
|
-
//
|
|
6
|
-
// Template variables interpolated before execution:
|
|
7
|
-
// ${{ args.rooms }}
|
|
8
|
-
// ${{ args.date | default("") }}
|
|
9
|
-
|
|
10
|
-
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
11
|
-
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
12
|
-
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
13
|
-
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
14
|
-
|
|
15
|
-
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
16
|
-
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
17
|
-
|
|
18
|
-
function todayLocalISO() {
|
|
19
|
-
const d = new Date();
|
|
20
|
-
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** "2026-06-18" -> "18, June, 2026" (matches the OWA date-cell aria-label). */
|
|
24
|
-
function dateCellLabel(iso) {
|
|
25
|
-
const [y, m, d] = iso.split('-').map(Number);
|
|
26
|
-
return `${d}, ${MONTHS[m - 1]}, ${y}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** querySelector shorthand, returns null if not found. */
|
|
30
|
-
function $(sel) { return document.querySelector(sel); }
|
|
31
|
-
|
|
32
|
-
/** True when an element matching the selector exists. */
|
|
33
|
-
function exists(sel) { return !!$(sel); }
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Click a button/link by accessible name (aria-label OR visible text).
|
|
37
|
-
* Returns true if an element was found and clicked.
|
|
38
|
-
*/
|
|
39
|
-
function clickByName(name) {
|
|
40
|
-
const els = Array.from(document.querySelectorAll('button,a,[role=button]'));
|
|
41
|
-
const el = els.find(e => {
|
|
42
|
-
if (!e.offsetParent) return false;
|
|
43
|
-
const al = (e.getAttribute('aria-label') || '').trim();
|
|
44
|
-
const tx = (e.textContent || '').replace(/\s+/g, ' ').trim();
|
|
45
|
-
return al === name || al.includes(name) || tx.includes(name);
|
|
46
|
-
});
|
|
47
|
-
if (!el) return false;
|
|
48
|
-
el.scrollIntoView({ block: 'center' });
|
|
49
|
-
el.click();
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** True when the Scheduling Assistant view (with its Start date picker) is rendered. */
|
|
54
|
-
function schedulingAssistantOpen() {
|
|
55
|
-
return Array.from(document.querySelectorAll('input')).some(
|
|
56
|
-
e => (e.getAttribute('aria-label') || '') === 'Start date'
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Type text into an input by selector (focus, set value, dispatch events). */
|
|
61
|
-
function typeText(sel, text) {
|
|
62
|
-
const el = $(sel);
|
|
63
|
-
if (!el) throw new Error(`Element "${sel}" not found for typing`);
|
|
64
|
-
el.focus();
|
|
65
|
-
const setter = Object.getOwnPropertyDescriptor(
|
|
66
|
-
window.HTMLInputElement.prototype, 'value'
|
|
67
|
-
).set;
|
|
68
|
-
setter.call(el, text);
|
|
69
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
70
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Clear and retype into an input using execCommand, which triggers React's internal events. */
|
|
74
|
-
function reactType(sel, text) {
|
|
75
|
-
const el = $(sel);
|
|
76
|
-
if (!el) throw new Error(`Element "${sel}" not found for typing`);
|
|
77
|
-
el.focus();
|
|
78
|
-
el.select();
|
|
79
|
-
document.execCommand('insertText', false, text);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── Fetch interceptor ────────────────────────────────────────────────
|
|
83
|
-
// Captures getSchedule GraphQL responses (the page's own authenticated
|
|
84
|
-
// request — the endpoint 401s on replay).
|
|
85
|
-
if (!window.__rfb) {
|
|
86
|
-
window.__rfb = [];
|
|
87
|
-
const origFetch = window.fetch;
|
|
88
|
-
window.fetch = function () {
|
|
89
|
-
const a = arguments;
|
|
90
|
-
const u = (a[0] && a[0].url) || a[0] || '';
|
|
91
|
-
const rb = (a[1] && a[1].body) || '';
|
|
92
|
-
return origFetch.apply(this, a).then(r => {
|
|
93
|
-
try {
|
|
94
|
-
if (String(u).indexOf('graphql') > -1) {
|
|
95
|
-
r.clone().text().then(t => {
|
|
96
|
-
if (t.indexOf('getSchedule') > -1 && t.indexOf('availabilityView') > -1) {
|
|
97
|
-
window.__rfb.push({ req: String(rb || ''), body: t });
|
|
98
|
-
}
|
|
99
|
-
}).catch(() => {});
|
|
100
|
-
}
|
|
101
|
-
} catch (_e) { /* ignore */ }
|
|
102
|
-
return r;
|
|
103
|
-
});
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Read and clear captured {req, body} pairs. */
|
|
108
|
-
function readCapture() {
|
|
109
|
-
const d = window.__rfb || [];
|
|
110
|
-
window.__rfb = [];
|
|
111
|
-
return d;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Map of scheduleId (lowercased) -> schedule object from a captured response body. */
|
|
115
|
-
function schedulesFromPair(pair) {
|
|
116
|
-
let body;
|
|
117
|
-
try { body = JSON.parse(pair.body); } catch (_e) { return new Map(); }
|
|
118
|
-
const byId = new Map();
|
|
119
|
-
const nodes = Array.isArray(body) ? body : [body];
|
|
120
|
-
for (const node of nodes) {
|
|
121
|
-
const schedules = node?.data?.getSchedule?.schedules;
|
|
122
|
-
if (Array.isArray(schedules)) {
|
|
123
|
-
for (const s of schedules) {
|
|
124
|
-
if (s?.scheduleId) byId.set(String(s.scheduleId).toLowerCase(), s);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return byId;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Build a free/busy timeline (busy/tentative/oof/elsewhere blocks + free gaps)
|
|
133
|
-
* for one local day from getSchedule scheduleItems.
|
|
134
|
-
*/
|
|
135
|
-
function buildTimeline(items, dateIso) {
|
|
136
|
-
const PRIORITY = { oof: 4, busy: 3, tentative: 2, elsewhere: 1, free: 0 };
|
|
137
|
-
const mapStatus = st => ({
|
|
138
|
-
Busy: 'busy', Tentative: 'tentative', Oof: 'oof', WorkingElsewhere: 'elsewhere', Free: 'free',
|
|
139
|
-
}[st] || 'busy');
|
|
140
|
-
|
|
141
|
-
const dayStart = new Date(`${dateIso}T00:00:00`);
|
|
142
|
-
const dayEnd = new Date(dayStart.getTime() + 86400000);
|
|
143
|
-
const fmt = ms => {
|
|
144
|
-
if (ms >= dayEnd.getTime()) return '24:00';
|
|
145
|
-
const d = new Date(ms);
|
|
146
|
-
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const blocks = [];
|
|
150
|
-
for (const it of items) {
|
|
151
|
-
if (!it || !it.startTime || !it.endTime) continue;
|
|
152
|
-
const s = new Date(it.startTime.dateTime).getTime();
|
|
153
|
-
const e = new Date(it.endTime.dateTime).getTime();
|
|
154
|
-
const cs = Math.max(s, dayStart.getTime());
|
|
155
|
-
const ce = Math.min(e, dayEnd.getTime());
|
|
156
|
-
if (ce > cs) blocks.push({ state: mapStatus(it.status), s: cs, e: ce });
|
|
157
|
-
}
|
|
158
|
-
blocks.sort((a, b) => a.s - b.s);
|
|
159
|
-
|
|
160
|
-
// Merge overlapping/adjacent busy blocks.
|
|
161
|
-
const merged = [];
|
|
162
|
-
for (const b of blocks) {
|
|
163
|
-
const last = merged[merged.length - 1];
|
|
164
|
-
if (last && b.s <= last.e) {
|
|
165
|
-
last.e = Math.max(last.e, b.e);
|
|
166
|
-
if (PRIORITY[b.state] > PRIORITY[last.state]) last.state = b.state;
|
|
167
|
-
} else {
|
|
168
|
-
merged.push({ ...b });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const rows = [];
|
|
173
|
-
let cursor = dayStart.getTime();
|
|
174
|
-
for (const b of merged) {
|
|
175
|
-
if (b.s > cursor) rows.push({ state: 'free', s: cursor, e: b.s });
|
|
176
|
-
rows.push({ state: b.state, s: b.s, e: b.e });
|
|
177
|
-
cursor = b.e;
|
|
178
|
-
}
|
|
179
|
-
if (cursor < dayEnd.getTime()) rows.push({ state: 'free', s: cursor, e: dayEnd.getTime() });
|
|
180
|
-
|
|
181
|
-
return rows.map(r => ({
|
|
182
|
-
date: dateIso,
|
|
183
|
-
state: r.state,
|
|
184
|
-
start: fmt(r.s),
|
|
185
|
-
end: fmt(r.e),
|
|
186
|
-
durationMin: Math.round((r.e - r.s) / 60000),
|
|
187
|
-
}));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── Parse arguments ──────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
const rawRooms = '${{ args.rooms }}'.trim();
|
|
193
|
-
if (!rawRooms) throw new Error('Missing rooms argument — pass comma-separated room names or emails');
|
|
194
|
-
const roomTokens = rawRooms.split(',').map(r => r.trim()).filter(r => r.length > 0);
|
|
195
|
-
if (roomTokens.length === 0) throw new Error('No rooms provided after parsing');
|
|
196
|
-
|
|
197
|
-
const parsedRooms = roomTokens.map(r => {
|
|
198
|
-
const colonIdx = r.lastIndexOf(':');
|
|
199
|
-
if (colonIdx > 0) {
|
|
200
|
-
const name = r.substring(0, colonIdx).trim();
|
|
201
|
-
const email = r.substring(colonIdx + 1).trim();
|
|
202
|
-
if (EMAIL_RE.test(email)) {
|
|
203
|
-
return { name, email: email.toLowerCase(), mode: 'pair' };
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (EMAIL_RE.test(r)) {
|
|
207
|
-
return { name: r, email: r.toLowerCase(), mode: 'email' };
|
|
208
|
-
}
|
|
209
|
-
return { name: r, email: null, mode: 'name' };
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
const canBatch = parsedRooms.every(r => r.mode === 'pair');
|
|
213
|
-
|
|
214
|
-
let date = '${{ args.date | default("") }}'.trim();
|
|
215
|
-
if (!date) date = todayLocalISO();
|
|
216
|
-
if (!DATE_RE.test(date)) throw new Error(`Invalid date "${date}" — use YYYY-MM-DD`);
|
|
217
|
-
|
|
218
|
-
// ── Wait for compose form ────────────────────────────────────────────
|
|
219
|
-
|
|
220
|
-
const __deadline = Date.now() + 30000;
|
|
221
|
-
while (Date.now() < __deadline) {
|
|
222
|
-
if (exists("button[aria-label='Open Scheduling Assistant']") ||
|
|
223
|
-
exists("input[aria-label='Start date']")) break;
|
|
224
|
-
await sleep(400);
|
|
225
|
-
}
|
|
226
|
-
if (!exists("button[aria-label='Open Scheduling Assistant']") &&
|
|
227
|
-
!exists("input[aria-label='Start date']")) {
|
|
228
|
-
if (/login\.|\/oauth2\/|signin|sso/i.test(location.href)) {
|
|
229
|
-
throw new Error('Not signed in to Outlook/Teams — log in and retry');
|
|
230
|
-
}
|
|
231
|
-
throw new Error('Calendar compose form did not load');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ── Open the Scheduling Assistant ────────────────────────────────────
|
|
235
|
-
|
|
236
|
-
for (let i = 0; i < 20 && !schedulingAssistantOpen(); i++) {
|
|
237
|
-
clickByName('Open Scheduling Assistant');
|
|
238
|
-
await sleep(400);
|
|
239
|
-
}
|
|
240
|
-
if (!schedulingAssistantOpen()) {
|
|
241
|
-
throw new Error('Could not open the Scheduling Assistant');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ── Set the date ─────────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
const cellSel = `button[aria-label='${dateCellLabel(date)}']`;
|
|
247
|
-
const target = new Date(`${date}T00:00:00`);
|
|
248
|
-
const now = new Date();
|
|
249
|
-
const navSel = target >= new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
250
|
-
? "button[aria-label^='Go to next month']"
|
|
251
|
-
: "button[aria-label^='Go to previous month']";
|
|
252
|
-
|
|
253
|
-
const dateInput = $("input[aria-label='Start date']");
|
|
254
|
-
if (dateInput) dateInput.click();
|
|
255
|
-
|
|
256
|
-
for (let i = 0; i < 30; i++) {
|
|
257
|
-
if (exists(cellSel)) {
|
|
258
|
-
$(cellSel).click();
|
|
259
|
-
break;
|
|
260
|
-
}
|
|
261
|
-
if (exists(navSel)) {
|
|
262
|
-
try { $(navSel).click(); } catch (_e) { /* retry */ }
|
|
263
|
-
} else {
|
|
264
|
-
const di = $("input[aria-label='Start date']");
|
|
265
|
-
if (di) di.click();
|
|
266
|
-
}
|
|
267
|
-
await sleep(500);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── Capture pre-existing mailbox IDs (organizer) ─────────────────────
|
|
271
|
-
|
|
272
|
-
const seen = new Set();
|
|
273
|
-
for (let i = 0; i < 6; i++) {
|
|
274
|
-
await sleep(300);
|
|
275
|
-
for (const pair of readCapture()) {
|
|
276
|
-
for (const id of schedulesFromPair(pair).keys()) seen.add(id);
|
|
277
|
-
}
|
|
278
|
-
if (seen.size > 0) break;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── Add room by email (required attendees path) ──────────────────────
|
|
282
|
-
|
|
283
|
-
async function addRoomByEmail(email) {
|
|
284
|
-
clickByName('Expand Required attendees');
|
|
285
|
-
await sleep(200);
|
|
286
|
-
|
|
287
|
-
const inputSel = "input[aria-label*='required attendees']";
|
|
288
|
-
for (let i = 0; i < 20 && !exists(inputSel); i++) {
|
|
289
|
-
clickByName('Add required attendee');
|
|
290
|
-
await sleep(200);
|
|
291
|
-
}
|
|
292
|
-
if (!exists(inputSel)) {
|
|
293
|
-
throw new Error('Could not open the required-attendee field');
|
|
294
|
-
}
|
|
295
|
-
typeText(inputSel, email);
|
|
296
|
-
|
|
297
|
-
const optionSel = `[role=option][aria-label*='${email}']`;
|
|
298
|
-
let found = false;
|
|
299
|
-
for (let i = 0; i < 40 && !found; i++) {
|
|
300
|
-
await sleep(200);
|
|
301
|
-
found = exists(optionSel);
|
|
302
|
-
}
|
|
303
|
-
if (!found) {
|
|
304
|
-
throw new Error(`Room "${email}" was not found in the directory`);
|
|
305
|
-
}
|
|
306
|
-
$(optionSel).click();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ── Add room by name (room finder path) ──────────────────────────────
|
|
310
|
-
|
|
311
|
-
async function addRoomByName(name) {
|
|
312
|
-
const inputSel = "input[aria-label='Add a room']";
|
|
313
|
-
|
|
314
|
-
// If the input is already visible (reuse after dismissRoom), clear and retype.
|
|
315
|
-
// Otherwise, click "Add a room" to open a fresh finder.
|
|
316
|
-
if (exists(inputSel)) {
|
|
317
|
-
reactType(inputSel, name);
|
|
318
|
-
// Wait for OWA to process the search and refresh suggestions.
|
|
319
|
-
await sleep(1000);
|
|
320
|
-
} else {
|
|
321
|
-
for (let i = 0; i < 30 && !exists(inputSel); i++) {
|
|
322
|
-
clickByName('Add a room');
|
|
323
|
-
await sleep(200);
|
|
324
|
-
}
|
|
325
|
-
if (!exists(inputSel)) {
|
|
326
|
-
throw new Error('Could not open the room finder');
|
|
327
|
-
}
|
|
328
|
-
typeText(inputSel, name);
|
|
329
|
-
// Wait for OWA to process the search and refresh suggestions.
|
|
330
|
-
await sleep(1500);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
let label = null;
|
|
334
|
-
for (let i = 0; i < 40 && !label; i++) {
|
|
335
|
-
await sleep(200);
|
|
336
|
-
const opts = Array.from(document.querySelectorAll('[role=option]'));
|
|
337
|
-
const el = opts.find(o => /capacity/i.test(o.getAttribute('aria-label') || '') && o.offsetParent);
|
|
338
|
-
if (el) {
|
|
339
|
-
el.scrollIntoView({ block: 'center' });
|
|
340
|
-
el.click();
|
|
341
|
-
label = el.getAttribute('aria-label');
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (!label) {
|
|
345
|
-
throw new Error(`No room matched "${name}"`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ── Dismiss room: remove the just-added room so "Add a room" reappears ──
|
|
350
|
-
|
|
351
|
-
async function dismissRoom() {
|
|
352
|
-
// Click the "Remove <room>" button on the most recently added room pill.
|
|
353
|
-
// OWA labels these as e.g. "Remove MY_Site_Building_L4_Room Name."
|
|
354
|
-
const removeBtn = Array.from(document.querySelectorAll('button'))
|
|
355
|
-
.filter(el => el.offsetParent)
|
|
356
|
-
.find(el => (el.getAttribute('aria-label') || '').startsWith('Remove '));
|
|
357
|
-
if (removeBtn) {
|
|
358
|
-
removeBtn.scrollIntoView({ block: 'center' });
|
|
359
|
-
removeBtn.click();
|
|
360
|
-
await sleep(200);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
await sleep(200);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ── Capture schedule for the most recently added room ────────────────
|
|
367
|
-
|
|
368
|
-
async function captureNewSchedule(knownIds) {
|
|
369
|
-
let newId = null;
|
|
370
|
-
let schedule = null;
|
|
371
|
-
let sawView = false;
|
|
372
|
-
let errMsg = null;
|
|
373
|
-
const itemsById = new Map();
|
|
374
|
-
|
|
375
|
-
for (let i = 0; i < 60; i++) {
|
|
376
|
-
await sleep(100);
|
|
377
|
-
for (const pair of readCapture()) {
|
|
378
|
-
for (const [id, s] of schedulesFromPair(pair)) {
|
|
379
|
-
if (knownIds.has(id)) continue;
|
|
380
|
-
if (!newId) newId = id;
|
|
381
|
-
if (id !== newId) continue;
|
|
382
|
-
schedule = s;
|
|
383
|
-
if (s.error) errMsg = s.error.message || s.error.responseCode || 'unknown';
|
|
384
|
-
if (s.availabilityView && s.availabilityView.length) sawView = true;
|
|
385
|
-
for (const it of (s.scheduleItems || [])) {
|
|
386
|
-
const key = it && it.id ? it.id : JSON.stringify(it && [it.startTime, it.endTime, it.subject]);
|
|
387
|
-
if (it) itemsById.set(key, it);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (sawView) break;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return { newId, schedule, sawView, errMsg, items: [...itemsById.values()] };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Batch capture: wait for getSchedule responses covering all expected emails.
|
|
399
|
-
* Returns a Map of email (lowercased) -> { schedule, items, sawView, errMsg }.
|
|
400
|
-
*/
|
|
401
|
-
async function captureBatchSchedules(expectedEmails, knownIds) {
|
|
402
|
-
const byEmail = new Map();
|
|
403
|
-
|
|
404
|
-
for (let i = 0; i < 80; i++) {
|
|
405
|
-
await sleep(100);
|
|
406
|
-
for (const pair of readCapture()) {
|
|
407
|
-
for (const [id, s] of schedulesFromPair(pair)) {
|
|
408
|
-
if (knownIds.has(id)) continue;
|
|
409
|
-
const idLower = id.toLowerCase();
|
|
410
|
-
if (!expectedEmails.has(idLower)) continue;
|
|
411
|
-
const items = (s.scheduleItems || []).filter(Boolean);
|
|
412
|
-
byEmail.set(idLower, {
|
|
413
|
-
schedule: s,
|
|
414
|
-
items,
|
|
415
|
-
sawView: !!(s.availabilityView && s.availabilityView.length),
|
|
416
|
-
errMsg: s.error ? (s.error.message || s.error.responseCode || 'unknown') : null,
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
if (expectedEmails.size > 0 && [...expectedEmails].every(e => byEmail.has(e))) break;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return byEmail;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// ── Room processing ──────────────────────────────────────────────────
|
|
427
|
-
|
|
428
|
-
const allRows = [];
|
|
429
|
-
const knownIds = new Set(seen);
|
|
430
|
-
|
|
431
|
-
if (canBatch) {
|
|
432
|
-
// ── Batch mode: add all rooms via room finder, single capture ──────
|
|
433
|
-
const emailToName = new Map();
|
|
434
|
-
const expectedEmails = new Set();
|
|
435
|
-
for (const r of parsedRooms) {
|
|
436
|
-
emailToName.set(r.email, r.name);
|
|
437
|
-
expectedEmails.add(r.email);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Clear stale captures, then add all rooms without dismissing.
|
|
441
|
-
readCapture();
|
|
442
|
-
|
|
443
|
-
for (const r of parsedRooms) {
|
|
444
|
-
try {
|
|
445
|
-
await addRoomByName(r.name);
|
|
446
|
-
await sleep(300);
|
|
447
|
-
} catch (addErr) {
|
|
448
|
-
allRows.push({
|
|
449
|
-
roomName: r.name, roomEmail: r.email, date,
|
|
450
|
-
state: 'error', start: addErr.message, end: '', durationMin: 0,
|
|
451
|
-
});
|
|
452
|
-
expectedEmails.delete(r.email);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Single batch capture for all successfully added rooms.
|
|
457
|
-
if (expectedEmails.size > 0) {
|
|
458
|
-
const results = await captureBatchSchedules(expectedEmails, knownIds);
|
|
459
|
-
|
|
460
|
-
for (const [email, data] of results) {
|
|
461
|
-
const name = emailToName.get(email);
|
|
462
|
-
knownIds.add(email);
|
|
463
|
-
if (data.errMsg && !data.sawView) {
|
|
464
|
-
allRows.push({
|
|
465
|
-
roomName: name, roomEmail: email, date,
|
|
466
|
-
state: 'error', start: `Free/busy error: ${data.errMsg}`,
|
|
467
|
-
end: '', durationMin: 0,
|
|
468
|
-
});
|
|
469
|
-
} else {
|
|
470
|
-
const timeline = buildTimeline(data.items, date);
|
|
471
|
-
for (const row of timeline) {
|
|
472
|
-
allRows.push({ roomName: name, roomEmail: email, ...row });
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Report rooms not found in any captured response.
|
|
478
|
-
for (const email of expectedEmails) {
|
|
479
|
-
if (!results.has(email)) {
|
|
480
|
-
allRows.push({
|
|
481
|
-
roomName: emailToName.get(email), roomEmail: email, date,
|
|
482
|
-
state: 'error', start: `No free/busy returned for "${emailToName.get(email)}" on ${date}`,
|
|
483
|
-
end: '', durationMin: 0,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
} else {
|
|
489
|
-
// ── Sequential mode (original behavior) ────────────────────────────
|
|
490
|
-
for (const r of parsedRooms) {
|
|
491
|
-
const isEmail = r.mode === 'email';
|
|
492
|
-
|
|
493
|
-
// Clear any pending captures before adding this room.
|
|
494
|
-
readCapture();
|
|
495
|
-
|
|
496
|
-
try {
|
|
497
|
-
if (isEmail) {
|
|
498
|
-
await addRoomByEmail(r.name);
|
|
499
|
-
} else {
|
|
500
|
-
await addRoomByName(r.name);
|
|
501
|
-
}
|
|
502
|
-
} catch (addErr) {
|
|
503
|
-
allRows.push({
|
|
504
|
-
roomName: r.name, roomEmail: r.email || '', date,
|
|
505
|
-
state: 'error', start: addErr.message, end: '', durationMin: 0,
|
|
506
|
-
});
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Capture the getSchedule response immediately after adding (before dismiss).
|
|
511
|
-
const result = await captureNewSchedule(knownIds);
|
|
512
|
-
|
|
513
|
-
// Remove the room so "Add a room" reappears for the next room.
|
|
514
|
-
await dismissRoom();
|
|
515
|
-
|
|
516
|
-
if (!result.newId) {
|
|
517
|
-
allRows.push({
|
|
518
|
-
roomName: r.name, roomEmail: r.email || '', date,
|
|
519
|
-
state: 'error', start: `No free/busy returned for "${r.name}" on ${date}`,
|
|
520
|
-
end: '', durationMin: 0,
|
|
521
|
-
});
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
knownIds.add(result.newId);
|
|
526
|
-
|
|
527
|
-
if (result.errMsg && !result.sawView) {
|
|
528
|
-
allRows.push({
|
|
529
|
-
roomName: r.name, roomEmail: result.newId, date,
|
|
530
|
-
state: 'error', start: `Free/busy error: ${result.errMsg}`,
|
|
531
|
-
end: '', durationMin: 0,
|
|
532
|
-
});
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const timeline = buildTimeline(result.items, date);
|
|
537
|
-
for (const row of timeline) {
|
|
538
|
-
allRows.push({ roomName: r.name, roomEmail: result.newId, ...row });
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (allRows.length === 0) {
|
|
544
|
-
throw new Error(`No results for any of the ${parsedRooms.length} room(s) on ${date}`);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return allRows;
|
|
1
|
+
// Runs in page context via js_evaluate step.
|
|
2
|
+
// Drives the Outlook Scheduling Assistant to fetch free/busy timelines for
|
|
3
|
+
// multiple meeting rooms on a single day. Rooms are added sequentially so we
|
|
4
|
+
// can reliably map each scheduleId back to the input room name.
|
|
5
|
+
//
|
|
6
|
+
// Template variables interpolated before execution:
|
|
7
|
+
// ${{ args.rooms }}
|
|
8
|
+
// ${{ args.date | default("") }}
|
|
9
|
+
|
|
10
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
11
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
12
|
+
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
13
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
14
|
+
|
|
15
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
16
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
17
|
+
|
|
18
|
+
function todayLocalISO() {
|
|
19
|
+
const d = new Date();
|
|
20
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** "2026-06-18" -> "18, June, 2026" (matches the OWA date-cell aria-label). */
|
|
24
|
+
function dateCellLabel(iso) {
|
|
25
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
26
|
+
return `${d}, ${MONTHS[m - 1]}, ${y}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** querySelector shorthand, returns null if not found. */
|
|
30
|
+
function $(sel) { return document.querySelector(sel); }
|
|
31
|
+
|
|
32
|
+
/** True when an element matching the selector exists. */
|
|
33
|
+
function exists(sel) { return !!$(sel); }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Click a button/link by accessible name (aria-label OR visible text).
|
|
37
|
+
* Returns true if an element was found and clicked.
|
|
38
|
+
*/
|
|
39
|
+
function clickByName(name) {
|
|
40
|
+
const els = Array.from(document.querySelectorAll('button,a,[role=button]'));
|
|
41
|
+
const el = els.find(e => {
|
|
42
|
+
if (!e.offsetParent) return false;
|
|
43
|
+
const al = (e.getAttribute('aria-label') || '').trim();
|
|
44
|
+
const tx = (e.textContent || '').replace(/\s+/g, ' ').trim();
|
|
45
|
+
return al === name || al.includes(name) || tx.includes(name);
|
|
46
|
+
});
|
|
47
|
+
if (!el) return false;
|
|
48
|
+
el.scrollIntoView({ block: 'center' });
|
|
49
|
+
el.click();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when the Scheduling Assistant view (with its Start date picker) is rendered. */
|
|
54
|
+
function schedulingAssistantOpen() {
|
|
55
|
+
return Array.from(document.querySelectorAll('input')).some(
|
|
56
|
+
e => (e.getAttribute('aria-label') || '') === 'Start date'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Type text into an input by selector (focus, set value, dispatch events). */
|
|
61
|
+
function typeText(sel, text) {
|
|
62
|
+
const el = $(sel);
|
|
63
|
+
if (!el) throw new Error(`Element "${sel}" not found for typing`);
|
|
64
|
+
el.focus();
|
|
65
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
66
|
+
window.HTMLInputElement.prototype, 'value'
|
|
67
|
+
).set;
|
|
68
|
+
setter.call(el, text);
|
|
69
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
70
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear and retype into an input using execCommand, which triggers React's internal events. */
|
|
74
|
+
function reactType(sel, text) {
|
|
75
|
+
const el = $(sel);
|
|
76
|
+
if (!el) throw new Error(`Element "${sel}" not found for typing`);
|
|
77
|
+
el.focus();
|
|
78
|
+
el.select();
|
|
79
|
+
document.execCommand('insertText', false, text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Fetch interceptor ────────────────────────────────────────────────
|
|
83
|
+
// Captures getSchedule GraphQL responses (the page's own authenticated
|
|
84
|
+
// request — the endpoint 401s on replay).
|
|
85
|
+
if (!window.__rfb) {
|
|
86
|
+
window.__rfb = [];
|
|
87
|
+
const origFetch = window.fetch;
|
|
88
|
+
window.fetch = function () {
|
|
89
|
+
const a = arguments;
|
|
90
|
+
const u = (a[0] && a[0].url) || a[0] || '';
|
|
91
|
+
const rb = (a[1] && a[1].body) || '';
|
|
92
|
+
return origFetch.apply(this, a).then(r => {
|
|
93
|
+
try {
|
|
94
|
+
if (String(u).indexOf('graphql') > -1) {
|
|
95
|
+
r.clone().text().then(t => {
|
|
96
|
+
if (t.indexOf('getSchedule') > -1 && t.indexOf('availabilityView') > -1) {
|
|
97
|
+
window.__rfb.push({ req: String(rb || ''), body: t });
|
|
98
|
+
}
|
|
99
|
+
}).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
} catch (_e) { /* ignore */ }
|
|
102
|
+
return r;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read and clear captured {req, body} pairs. */
|
|
108
|
+
function readCapture() {
|
|
109
|
+
const d = window.__rfb || [];
|
|
110
|
+
window.__rfb = [];
|
|
111
|
+
return d;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Map of scheduleId (lowercased) -> schedule object from a captured response body. */
|
|
115
|
+
function schedulesFromPair(pair) {
|
|
116
|
+
let body;
|
|
117
|
+
try { body = JSON.parse(pair.body); } catch (_e) { return new Map(); }
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
const nodes = Array.isArray(body) ? body : [body];
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const schedules = node?.data?.getSchedule?.schedules;
|
|
122
|
+
if (Array.isArray(schedules)) {
|
|
123
|
+
for (const s of schedules) {
|
|
124
|
+
if (s?.scheduleId) byId.set(String(s.scheduleId).toLowerCase(), s);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return byId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build a free/busy timeline (busy/tentative/oof/elsewhere blocks + free gaps)
|
|
133
|
+
* for one local day from getSchedule scheduleItems.
|
|
134
|
+
*/
|
|
135
|
+
function buildTimeline(items, dateIso) {
|
|
136
|
+
const PRIORITY = { oof: 4, busy: 3, tentative: 2, elsewhere: 1, free: 0 };
|
|
137
|
+
const mapStatus = st => ({
|
|
138
|
+
Busy: 'busy', Tentative: 'tentative', Oof: 'oof', WorkingElsewhere: 'elsewhere', Free: 'free',
|
|
139
|
+
}[st] || 'busy');
|
|
140
|
+
|
|
141
|
+
const dayStart = new Date(`${dateIso}T00:00:00`);
|
|
142
|
+
const dayEnd = new Date(dayStart.getTime() + 86400000);
|
|
143
|
+
const fmt = ms => {
|
|
144
|
+
if (ms >= dayEnd.getTime()) return '24:00';
|
|
145
|
+
const d = new Date(ms);
|
|
146
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const blocks = [];
|
|
150
|
+
for (const it of items) {
|
|
151
|
+
if (!it || !it.startTime || !it.endTime) continue;
|
|
152
|
+
const s = new Date(it.startTime.dateTime).getTime();
|
|
153
|
+
const e = new Date(it.endTime.dateTime).getTime();
|
|
154
|
+
const cs = Math.max(s, dayStart.getTime());
|
|
155
|
+
const ce = Math.min(e, dayEnd.getTime());
|
|
156
|
+
if (ce > cs) blocks.push({ state: mapStatus(it.status), s: cs, e: ce });
|
|
157
|
+
}
|
|
158
|
+
blocks.sort((a, b) => a.s - b.s);
|
|
159
|
+
|
|
160
|
+
// Merge overlapping/adjacent busy blocks.
|
|
161
|
+
const merged = [];
|
|
162
|
+
for (const b of blocks) {
|
|
163
|
+
const last = merged[merged.length - 1];
|
|
164
|
+
if (last && b.s <= last.e) {
|
|
165
|
+
last.e = Math.max(last.e, b.e);
|
|
166
|
+
if (PRIORITY[b.state] > PRIORITY[last.state]) last.state = b.state;
|
|
167
|
+
} else {
|
|
168
|
+
merged.push({ ...b });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rows = [];
|
|
173
|
+
let cursor = dayStart.getTime();
|
|
174
|
+
for (const b of merged) {
|
|
175
|
+
if (b.s > cursor) rows.push({ state: 'free', s: cursor, e: b.s });
|
|
176
|
+
rows.push({ state: b.state, s: b.s, e: b.e });
|
|
177
|
+
cursor = b.e;
|
|
178
|
+
}
|
|
179
|
+
if (cursor < dayEnd.getTime()) rows.push({ state: 'free', s: cursor, e: dayEnd.getTime() });
|
|
180
|
+
|
|
181
|
+
return rows.map(r => ({
|
|
182
|
+
date: dateIso,
|
|
183
|
+
state: r.state,
|
|
184
|
+
start: fmt(r.s),
|
|
185
|
+
end: fmt(r.e),
|
|
186
|
+
durationMin: Math.round((r.e - r.s) / 60000),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Parse arguments ──────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const rawRooms = '${{ args.rooms }}'.trim();
|
|
193
|
+
if (!rawRooms) throw new Error('Missing rooms argument — pass comma-separated room names or emails');
|
|
194
|
+
const roomTokens = rawRooms.split(',').map(r => r.trim()).filter(r => r.length > 0);
|
|
195
|
+
if (roomTokens.length === 0) throw new Error('No rooms provided after parsing');
|
|
196
|
+
|
|
197
|
+
const parsedRooms = roomTokens.map(r => {
|
|
198
|
+
const colonIdx = r.lastIndexOf(':');
|
|
199
|
+
if (colonIdx > 0) {
|
|
200
|
+
const name = r.substring(0, colonIdx).trim();
|
|
201
|
+
const email = r.substring(colonIdx + 1).trim();
|
|
202
|
+
if (EMAIL_RE.test(email)) {
|
|
203
|
+
return { name, email: email.toLowerCase(), mode: 'pair' };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (EMAIL_RE.test(r)) {
|
|
207
|
+
return { name: r, email: r.toLowerCase(), mode: 'email' };
|
|
208
|
+
}
|
|
209
|
+
return { name: r, email: null, mode: 'name' };
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const canBatch = parsedRooms.every(r => r.mode === 'pair');
|
|
213
|
+
|
|
214
|
+
let date = '${{ args.date | default("") }}'.trim();
|
|
215
|
+
if (!date) date = todayLocalISO();
|
|
216
|
+
if (!DATE_RE.test(date)) throw new Error(`Invalid date "${date}" — use YYYY-MM-DD`);
|
|
217
|
+
|
|
218
|
+
// ── Wait for compose form ────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
const __deadline = Date.now() + 30000;
|
|
221
|
+
while (Date.now() < __deadline) {
|
|
222
|
+
if (exists("button[aria-label='Open Scheduling Assistant']") ||
|
|
223
|
+
exists("input[aria-label='Start date']")) break;
|
|
224
|
+
await sleep(400);
|
|
225
|
+
}
|
|
226
|
+
if (!exists("button[aria-label='Open Scheduling Assistant']") &&
|
|
227
|
+
!exists("input[aria-label='Start date']")) {
|
|
228
|
+
if (/login\.|\/oauth2\/|signin|sso/i.test(location.href)) {
|
|
229
|
+
throw new Error('Not signed in to Outlook/Teams — log in and retry');
|
|
230
|
+
}
|
|
231
|
+
throw new Error('Calendar compose form did not load');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Open the Scheduling Assistant ────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < 20 && !schedulingAssistantOpen(); i++) {
|
|
237
|
+
clickByName('Open Scheduling Assistant');
|
|
238
|
+
await sleep(400);
|
|
239
|
+
}
|
|
240
|
+
if (!schedulingAssistantOpen()) {
|
|
241
|
+
throw new Error('Could not open the Scheduling Assistant');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Set the date ─────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const cellSel = `button[aria-label='${dateCellLabel(date)}']`;
|
|
247
|
+
const target = new Date(`${date}T00:00:00`);
|
|
248
|
+
const now = new Date();
|
|
249
|
+
const navSel = target >= new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
250
|
+
? "button[aria-label^='Go to next month']"
|
|
251
|
+
: "button[aria-label^='Go to previous month']";
|
|
252
|
+
|
|
253
|
+
const dateInput = $("input[aria-label='Start date']");
|
|
254
|
+
if (dateInput) dateInput.click();
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < 30; i++) {
|
|
257
|
+
if (exists(cellSel)) {
|
|
258
|
+
$(cellSel).click();
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (exists(navSel)) {
|
|
262
|
+
try { $(navSel).click(); } catch (_e) { /* retry */ }
|
|
263
|
+
} else {
|
|
264
|
+
const di = $("input[aria-label='Start date']");
|
|
265
|
+
if (di) di.click();
|
|
266
|
+
}
|
|
267
|
+
await sleep(500);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Capture pre-existing mailbox IDs (organizer) ─────────────────────
|
|
271
|
+
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
for (let i = 0; i < 6; i++) {
|
|
274
|
+
await sleep(300);
|
|
275
|
+
for (const pair of readCapture()) {
|
|
276
|
+
for (const id of schedulesFromPair(pair).keys()) seen.add(id);
|
|
277
|
+
}
|
|
278
|
+
if (seen.size > 0) break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Add room by email (required attendees path) ──────────────────────
|
|
282
|
+
|
|
283
|
+
async function addRoomByEmail(email) {
|
|
284
|
+
clickByName('Expand Required attendees');
|
|
285
|
+
await sleep(200);
|
|
286
|
+
|
|
287
|
+
const inputSel = "input[aria-label*='required attendees']";
|
|
288
|
+
for (let i = 0; i < 20 && !exists(inputSel); i++) {
|
|
289
|
+
clickByName('Add required attendee');
|
|
290
|
+
await sleep(200);
|
|
291
|
+
}
|
|
292
|
+
if (!exists(inputSel)) {
|
|
293
|
+
throw new Error('Could not open the required-attendee field');
|
|
294
|
+
}
|
|
295
|
+
typeText(inputSel, email);
|
|
296
|
+
|
|
297
|
+
const optionSel = `[role=option][aria-label*='${email}']`;
|
|
298
|
+
let found = false;
|
|
299
|
+
for (let i = 0; i < 40 && !found; i++) {
|
|
300
|
+
await sleep(200);
|
|
301
|
+
found = exists(optionSel);
|
|
302
|
+
}
|
|
303
|
+
if (!found) {
|
|
304
|
+
throw new Error(`Room "${email}" was not found in the directory`);
|
|
305
|
+
}
|
|
306
|
+
$(optionSel).click();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Add room by name (room finder path) ──────────────────────────────
|
|
310
|
+
|
|
311
|
+
async function addRoomByName(name) {
|
|
312
|
+
const inputSel = "input[aria-label='Add a room']";
|
|
313
|
+
|
|
314
|
+
// If the input is already visible (reuse after dismissRoom), clear and retype.
|
|
315
|
+
// Otherwise, click "Add a room" to open a fresh finder.
|
|
316
|
+
if (exists(inputSel)) {
|
|
317
|
+
reactType(inputSel, name);
|
|
318
|
+
// Wait for OWA to process the search and refresh suggestions.
|
|
319
|
+
await sleep(1000);
|
|
320
|
+
} else {
|
|
321
|
+
for (let i = 0; i < 30 && !exists(inputSel); i++) {
|
|
322
|
+
clickByName('Add a room');
|
|
323
|
+
await sleep(200);
|
|
324
|
+
}
|
|
325
|
+
if (!exists(inputSel)) {
|
|
326
|
+
throw new Error('Could not open the room finder');
|
|
327
|
+
}
|
|
328
|
+
typeText(inputSel, name);
|
|
329
|
+
// Wait for OWA to process the search and refresh suggestions.
|
|
330
|
+
await sleep(1500);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let label = null;
|
|
334
|
+
for (let i = 0; i < 40 && !label; i++) {
|
|
335
|
+
await sleep(200);
|
|
336
|
+
const opts = Array.from(document.querySelectorAll('[role=option]'));
|
|
337
|
+
const el = opts.find(o => /capacity/i.test(o.getAttribute('aria-label') || '') && o.offsetParent);
|
|
338
|
+
if (el) {
|
|
339
|
+
el.scrollIntoView({ block: 'center' });
|
|
340
|
+
el.click();
|
|
341
|
+
label = el.getAttribute('aria-label');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!label) {
|
|
345
|
+
throw new Error(`No room matched "${name}"`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Dismiss room: remove the just-added room so "Add a room" reappears ──
|
|
350
|
+
|
|
351
|
+
async function dismissRoom() {
|
|
352
|
+
// Click the "Remove <room>" button on the most recently added room pill.
|
|
353
|
+
// OWA labels these as e.g. "Remove MY_Site_Building_L4_Room Name."
|
|
354
|
+
const removeBtn = Array.from(document.querySelectorAll('button'))
|
|
355
|
+
.filter(el => el.offsetParent)
|
|
356
|
+
.find(el => (el.getAttribute('aria-label') || '').startsWith('Remove '));
|
|
357
|
+
if (removeBtn) {
|
|
358
|
+
removeBtn.scrollIntoView({ block: 'center' });
|
|
359
|
+
removeBtn.click();
|
|
360
|
+
await sleep(200);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await sleep(200);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Capture schedule for the most recently added room ────────────────
|
|
367
|
+
|
|
368
|
+
async function captureNewSchedule(knownIds) {
|
|
369
|
+
let newId = null;
|
|
370
|
+
let schedule = null;
|
|
371
|
+
let sawView = false;
|
|
372
|
+
let errMsg = null;
|
|
373
|
+
const itemsById = new Map();
|
|
374
|
+
|
|
375
|
+
for (let i = 0; i < 60; i++) {
|
|
376
|
+
await sleep(100);
|
|
377
|
+
for (const pair of readCapture()) {
|
|
378
|
+
for (const [id, s] of schedulesFromPair(pair)) {
|
|
379
|
+
if (knownIds.has(id)) continue;
|
|
380
|
+
if (!newId) newId = id;
|
|
381
|
+
if (id !== newId) continue;
|
|
382
|
+
schedule = s;
|
|
383
|
+
if (s.error) errMsg = s.error.message || s.error.responseCode || 'unknown';
|
|
384
|
+
if (s.availabilityView && s.availabilityView.length) sawView = true;
|
|
385
|
+
for (const it of (s.scheduleItems || [])) {
|
|
386
|
+
const key = it && it.id ? it.id : JSON.stringify(it && [it.startTime, it.endTime, it.subject]);
|
|
387
|
+
if (it) itemsById.set(key, it);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (sawView) break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { newId, schedule, sawView, errMsg, items: [...itemsById.values()] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Batch capture: wait for getSchedule responses covering all expected emails.
|
|
399
|
+
* Returns a Map of email (lowercased) -> { schedule, items, sawView, errMsg }.
|
|
400
|
+
*/
|
|
401
|
+
async function captureBatchSchedules(expectedEmails, knownIds) {
|
|
402
|
+
const byEmail = new Map();
|
|
403
|
+
|
|
404
|
+
for (let i = 0; i < 80; i++) {
|
|
405
|
+
await sleep(100);
|
|
406
|
+
for (const pair of readCapture()) {
|
|
407
|
+
for (const [id, s] of schedulesFromPair(pair)) {
|
|
408
|
+
if (knownIds.has(id)) continue;
|
|
409
|
+
const idLower = id.toLowerCase();
|
|
410
|
+
if (!expectedEmails.has(idLower)) continue;
|
|
411
|
+
const items = (s.scheduleItems || []).filter(Boolean);
|
|
412
|
+
byEmail.set(idLower, {
|
|
413
|
+
schedule: s,
|
|
414
|
+
items,
|
|
415
|
+
sawView: !!(s.availabilityView && s.availabilityView.length),
|
|
416
|
+
errMsg: s.error ? (s.error.message || s.error.responseCode || 'unknown') : null,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (expectedEmails.size > 0 && [...expectedEmails].every(e => byEmail.has(e))) break;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return byEmail;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Room processing ──────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
const allRows = [];
|
|
429
|
+
const knownIds = new Set(seen);
|
|
430
|
+
|
|
431
|
+
if (canBatch) {
|
|
432
|
+
// ── Batch mode: add all rooms via room finder, single capture ──────
|
|
433
|
+
const emailToName = new Map();
|
|
434
|
+
const expectedEmails = new Set();
|
|
435
|
+
for (const r of parsedRooms) {
|
|
436
|
+
emailToName.set(r.email, r.name);
|
|
437
|
+
expectedEmails.add(r.email);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Clear stale captures, then add all rooms without dismissing.
|
|
441
|
+
readCapture();
|
|
442
|
+
|
|
443
|
+
for (const r of parsedRooms) {
|
|
444
|
+
try {
|
|
445
|
+
await addRoomByName(r.name);
|
|
446
|
+
await sleep(300);
|
|
447
|
+
} catch (addErr) {
|
|
448
|
+
allRows.push({
|
|
449
|
+
roomName: r.name, roomEmail: r.email, date,
|
|
450
|
+
state: 'error', start: addErr.message, end: '', durationMin: 0,
|
|
451
|
+
});
|
|
452
|
+
expectedEmails.delete(r.email);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Single batch capture for all successfully added rooms.
|
|
457
|
+
if (expectedEmails.size > 0) {
|
|
458
|
+
const results = await captureBatchSchedules(expectedEmails, knownIds);
|
|
459
|
+
|
|
460
|
+
for (const [email, data] of results) {
|
|
461
|
+
const name = emailToName.get(email);
|
|
462
|
+
knownIds.add(email);
|
|
463
|
+
if (data.errMsg && !data.sawView) {
|
|
464
|
+
allRows.push({
|
|
465
|
+
roomName: name, roomEmail: email, date,
|
|
466
|
+
state: 'error', start: `Free/busy error: ${data.errMsg}`,
|
|
467
|
+
end: '', durationMin: 0,
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
const timeline = buildTimeline(data.items, date);
|
|
471
|
+
for (const row of timeline) {
|
|
472
|
+
allRows.push({ roomName: name, roomEmail: email, ...row });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Report rooms not found in any captured response.
|
|
478
|
+
for (const email of expectedEmails) {
|
|
479
|
+
if (!results.has(email)) {
|
|
480
|
+
allRows.push({
|
|
481
|
+
roomName: emailToName.get(email), roomEmail: email, date,
|
|
482
|
+
state: 'error', start: `No free/busy returned for "${emailToName.get(email)}" on ${date}`,
|
|
483
|
+
end: '', durationMin: 0,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
// ── Sequential mode (original behavior) ────────────────────────────
|
|
490
|
+
for (const r of parsedRooms) {
|
|
491
|
+
const isEmail = r.mode === 'email';
|
|
492
|
+
|
|
493
|
+
// Clear any pending captures before adding this room.
|
|
494
|
+
readCapture();
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
if (isEmail) {
|
|
498
|
+
await addRoomByEmail(r.name);
|
|
499
|
+
} else {
|
|
500
|
+
await addRoomByName(r.name);
|
|
501
|
+
}
|
|
502
|
+
} catch (addErr) {
|
|
503
|
+
allRows.push({
|
|
504
|
+
roomName: r.name, roomEmail: r.email || '', date,
|
|
505
|
+
state: 'error', start: addErr.message, end: '', durationMin: 0,
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Capture the getSchedule response immediately after adding (before dismiss).
|
|
511
|
+
const result = await captureNewSchedule(knownIds);
|
|
512
|
+
|
|
513
|
+
// Remove the room so "Add a room" reappears for the next room.
|
|
514
|
+
await dismissRoom();
|
|
515
|
+
|
|
516
|
+
if (!result.newId) {
|
|
517
|
+
allRows.push({
|
|
518
|
+
roomName: r.name, roomEmail: r.email || '', date,
|
|
519
|
+
state: 'error', start: `No free/busy returned for "${r.name}" on ${date}`,
|
|
520
|
+
end: '', durationMin: 0,
|
|
521
|
+
});
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
knownIds.add(result.newId);
|
|
526
|
+
|
|
527
|
+
if (result.errMsg && !result.sawView) {
|
|
528
|
+
allRows.push({
|
|
529
|
+
roomName: r.name, roomEmail: result.newId, date,
|
|
530
|
+
state: 'error', start: `Free/busy error: ${result.errMsg}`,
|
|
531
|
+
end: '', durationMin: 0,
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const timeline = buildTimeline(result.items, date);
|
|
537
|
+
for (const row of timeline) {
|
|
538
|
+
allRows.push({ roomName: r.name, roomEmail: result.newId, ...row });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (allRows.length === 0) {
|
|
544
|
+
throw new Error(`No results for any of the ${parsedRooms.length} room(s) on ${date}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return allRows;
|