nothumanallowed 4.1.0 → 6.0.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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Outlook Calendar API wrapper via Microsoft Graph — zero dependencies.
3
+ * All calls via native fetch to Microsoft Graph REST API.
4
+ *
5
+ * Output format matches google-calendar.mjs parsed shape for unified consumption.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
11
+ import { NHA_DIR } from '../constants.mjs';
12
+
13
+ const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
14
+ const CAL_DIR = path.join(NHA_DIR, 'ops', 'calendar');
15
+
16
+ /** Authenticated fetch with auto-retry on 401 */
17
+ async function graphFetch(config, urlPath, options = {}) {
18
+ const token = await getMicrosoftAccessToken(config);
19
+ const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
20
+
21
+ let res = await fetch(url, {
22
+ ...options,
23
+ headers: {
24
+ ...options.headers,
25
+ 'Authorization': `Bearer ${token}`,
26
+ 'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
27
+ },
28
+ });
29
+
30
+ if (res.status === 401) {
31
+ const newToken = await getMicrosoftAccessToken(config);
32
+ res = await fetch(url, {
33
+ ...options,
34
+ headers: {
35
+ ...options.headers,
36
+ 'Authorization': `Bearer ${newToken}`,
37
+ 'Prefer': 'outlook.timezone="' + Intl.DateTimeFormat().resolvedOptions().timeZone + '"',
38
+ },
39
+ });
40
+ }
41
+
42
+ if (!res.ok) {
43
+ const err = await res.text();
44
+ throw new Error(`Microsoft Calendar API ${res.status}: ${err}`);
45
+ }
46
+
47
+ return res.json();
48
+ }
49
+
50
+ /**
51
+ * List all calendars the user has access to.
52
+ */
53
+ export async function listCalendars(config) {
54
+ const data = await graphFetch(config, '/calendars');
55
+ return (data.value || []).map(c => ({
56
+ id: c.id,
57
+ summary: c.name,
58
+ primary: c.isDefaultCalendar || false,
59
+ timeZone: c.owner?.address ? undefined : undefined, // Graph doesn't expose per-calendar TZ the same way
60
+ accessRole: c.canEdit ? 'writer' : 'reader',
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * List events for a date range.
66
+ * @param {object} config
67
+ * @param {string} calendarId — calendar ID or 'primary' for default
68
+ * @param {Date} timeMin
69
+ * @param {Date} timeMax
70
+ * @returns {Promise<Array>} parsed events
71
+ */
72
+ export async function listEvents(config, calendarId = 'primary', timeMin, timeMax) {
73
+ const calPath = calendarId === 'primary'
74
+ ? '/calendar/calendarView'
75
+ : `/calendars/${calendarId}/calendarView`;
76
+
77
+ const params = new URLSearchParams({
78
+ startDateTime: timeMin.toISOString(),
79
+ endDateTime: timeMax.toISOString(),
80
+ '$top': '50',
81
+ '$orderby': 'start/dateTime',
82
+ '$select': 'id,subject,body,location,start,end,isAllDay,attendees,organizer,onlineMeeting,webLink,showAs,responseStatus',
83
+ });
84
+
85
+ const data = await graphFetch(config, `${calPath}?${params}`);
86
+ const events = (data.value || []).map(parseEvent);
87
+
88
+ // Cache events
89
+ cacheEvents(timeMin, events);
90
+ return events;
91
+ }
92
+
93
+ /**
94
+ * Get today's events from all calendars.
95
+ */
96
+ export async function getTodayEvents(config) {
97
+ const now = new Date();
98
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
99
+ const endOfDay = new Date(startOfDay.getTime() + 86400000);
100
+
101
+ const calendars = await listCalendars(config);
102
+ const allEvents = [];
103
+
104
+ for (const cal of calendars) {
105
+ try {
106
+ const events = await listEvents(config, cal.id, startOfDay, endOfDay);
107
+ for (const e of events) {
108
+ e.calendarName = cal.summary;
109
+ allEvents.push(e);
110
+ }
111
+ } catch { /* skip failed calendars */ }
112
+ }
113
+
114
+ // Sort by start time
115
+ allEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
116
+ return allEvents;
117
+ }
118
+
119
+ /**
120
+ * Get events for a specific date.
121
+ */
122
+ export async function getEventsForDate(config, date) {
123
+ const d = new Date(date);
124
+ const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
125
+ const endOfDay = new Date(startOfDay.getTime() + 86400000);
126
+ return listEvents(config, 'primary', startOfDay, endOfDay);
127
+ }
128
+
129
+ /**
130
+ * Get upcoming events (next N hours).
131
+ * @param {number} hours — look-ahead hours (default 2)
132
+ */
133
+ export async function getUpcomingEvents(config, hours = 2) {
134
+ const now = new Date();
135
+ const end = new Date(now.getTime() + hours * 3600000);
136
+ return listEvents(config, 'primary', now, end);
137
+ }
138
+
139
+ /**
140
+ * Create a new event.
141
+ * @param {object} config
142
+ * @param {object} event — { summary, start, end, description, location, attendees }
143
+ * @param {string} calendarId
144
+ */
145
+ export async function createEvent(config, event, calendarId = 'primary') {
146
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
147
+
148
+ const startStr = String(event.start);
149
+ const endStr = String(event.end);
150
+
151
+ // Normalize datetime — Microsoft Graph expects ISO format or { dateTime, timeZone }
152
+ const startDT = (startStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(startStr))
153
+ ? new Date(startStr).toISOString().replace('Z', '')
154
+ : startStr;
155
+ const endDT = (endStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(endStr))
156
+ ? new Date(endStr).toISOString().replace('Z', '')
157
+ : endStr;
158
+
159
+ const body = {
160
+ subject: event.summary,
161
+ body: {
162
+ contentType: 'Text',
163
+ content: event.description || '',
164
+ },
165
+ start: { dateTime: startDT, timeZone: tz },
166
+ end: { dateTime: endDT, timeZone: tz },
167
+ };
168
+
169
+ if (event.location) {
170
+ body.location = { displayName: event.location };
171
+ }
172
+
173
+ if (event.attendees?.length) {
174
+ body.attendees = event.attendees.map(email => ({
175
+ emailAddress: { address: email },
176
+ type: 'required',
177
+ }));
178
+ }
179
+
180
+ const calPath = calendarId === 'primary'
181
+ ? '/calendar/events'
182
+ : `/calendars/${calendarId}/events`;
183
+
184
+ return graphFetch(config, calPath, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify(body),
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Update an existing event (partial).
193
+ */
194
+ export async function updateEvent(config, calendarId, eventId, patch) {
195
+ // Translate Google-style patch to Microsoft Graph format
196
+ const body = {};
197
+
198
+ if (patch.summary) body.subject = patch.summary;
199
+ if (patch.description) body.body = { contentType: 'Text', content: patch.description };
200
+ if (patch.location) body.location = { displayName: patch.location };
201
+
202
+ if (patch.start) {
203
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
204
+ if (typeof patch.start === 'object' && patch.start.dateTime) {
205
+ body.start = { dateTime: patch.start.dateTime.replace('Z', ''), timeZone: tz };
206
+ } else {
207
+ body.start = { dateTime: new Date(patch.start).toISOString().replace('Z', ''), timeZone: tz };
208
+ }
209
+ }
210
+
211
+ if (patch.end) {
212
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
213
+ if (typeof patch.end === 'object' && patch.end.dateTime) {
214
+ body.end = { dateTime: patch.end.dateTime.replace('Z', ''), timeZone: tz };
215
+ } else {
216
+ body.end = { dateTime: new Date(patch.end).toISOString().replace('Z', ''), timeZone: tz };
217
+ }
218
+ }
219
+
220
+ const calPath = calendarId === 'primary'
221
+ ? `/calendar/events/${eventId}`
222
+ : `/calendars/${calendarId}/events/${eventId}`;
223
+
224
+ return graphFetch(config, calPath, {
225
+ method: 'PATCH',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify(body),
228
+ });
229
+ }
230
+
231
+ // ── Event Parser ───────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Parse Microsoft Graph event to unified format matching Google Calendar output.
235
+ */
236
+ function parseEvent(raw) {
237
+ const isAllDay = raw.isAllDay || false;
238
+
239
+ // Microsoft returns { dateTime, timeZone } objects for start/end
240
+ let start = '';
241
+ let end = '';
242
+
243
+ if (isAllDay) {
244
+ // All-day events: use date portion only (YYYY-MM-DD)
245
+ start = raw.start?.dateTime?.split('T')[0] || '';
246
+ end = raw.end?.dateTime?.split('T')[0] || '';
247
+ } else {
248
+ // Timed events: use full ISO datetime
249
+ start = raw.start?.dateTime || '';
250
+ end = raw.end?.dateTime || '';
251
+ }
252
+
253
+ // Build online meeting link (Teams or other)
254
+ const meetingLink = raw.onlineMeeting?.joinUrl || '';
255
+
256
+ return {
257
+ id: raw.id,
258
+ summary: raw.subject || '(no title)',
259
+ description: raw.body?.content
260
+ ? raw.body.content
261
+ .replace(/<[^>]+>/g, ' ')
262
+ .replace(/&nbsp;/g, ' ')
263
+ .replace(/\s+/g, ' ')
264
+ .trim()
265
+ .slice(0, 2000)
266
+ : '',
267
+ location: raw.location?.displayName || '',
268
+ start,
269
+ end,
270
+ isAllDay,
271
+ status: raw.showAs || 'busy',
272
+ attendees: (raw.attendees || []).map(a => ({
273
+ email: a.emailAddress?.address || '',
274
+ name: a.emailAddress?.name || '',
275
+ responseStatus: mapResponseStatus(a.status?.response),
276
+ self: false,
277
+ })),
278
+ organizer: raw.organizer?.emailAddress?.address || '',
279
+ hangoutLink: meetingLink, // unified field for any online meeting link
280
+ htmlLink: raw.webLink || '',
281
+ calendarName: '',
282
+ provider: 'microsoft',
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Map Microsoft response status to Google-compatible values.
288
+ */
289
+ function mapResponseStatus(msStatus) {
290
+ const map = {
291
+ none: 'needsAction',
292
+ organizer: 'accepted',
293
+ tentativelyAccepted: 'tentative',
294
+ accepted: 'accepted',
295
+ declined: 'declined',
296
+ notResponded: 'needsAction',
297
+ };
298
+ return map[msStatus] || 'needsAction';
299
+ }
300
+
301
+ // ── Local Cache ────────────────────────────────────────────────────────────
302
+
303
+ function cacheEvents(date, events) {
304
+ fs.mkdirSync(CAL_DIR, { recursive: true });
305
+ const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
306
+ const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
307
+ fs.writeFileSync(file, JSON.stringify(events, null, 2), { mode: 0o600 });
308
+ }
309
+
310
+ /**
311
+ * Load cached events for a date.
312
+ */
313
+ export function loadCachedEvents(date) {
314
+ const dateStr = date instanceof Date ? date.toISOString().split('T')[0] : String(date);
315
+ const file = path.join(CAL_DIR, `ms-events-${dateStr}.json`);
316
+ if (!fs.existsSync(file)) return null;
317
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); }
318
+ catch { return null; }
319
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Outlook Mail API wrapper via Microsoft Graph — zero dependencies.
3
+ * All calls via native fetch to Microsoft Graph REST API.
4
+ * Auto-refreshes tokens on 401.
5
+ *
6
+ * Output format matches google-gmail.mjs parsed shape for unified consumption.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getMicrosoftAccessToken } from './microsoft-oauth.mjs';
12
+ import { NHA_DIR } from '../constants.mjs';
13
+
14
+ const GRAPH_BASE = 'https://graph.microsoft.com/v1.0/me';
15
+ const MAIL_DIR = path.join(NHA_DIR, 'ops', 'mail');
16
+ const INBOX_DIR = path.join(MAIL_DIR, 'inbox');
17
+
18
+ /** Authenticated fetch with auto-retry on 401 */
19
+ async function graphFetch(config, urlPath, options = {}) {
20
+ const token = await getMicrosoftAccessToken(config);
21
+ const url = urlPath.startsWith('http') ? urlPath : `${GRAPH_BASE}${urlPath}`;
22
+
23
+ let res = await fetch(url, {
24
+ ...options,
25
+ headers: {
26
+ ...options.headers,
27
+ 'Authorization': `Bearer ${token}`,
28
+ },
29
+ });
30
+
31
+ // Retry once on 401 (token may have expired between check and request)
32
+ if (res.status === 401) {
33
+ const newToken = await getMicrosoftAccessToken(config);
34
+ res = await fetch(url, {
35
+ ...options,
36
+ headers: {
37
+ ...options.headers,
38
+ 'Authorization': `Bearer ${newToken}`,
39
+ },
40
+ });
41
+ }
42
+
43
+ if (!res.ok) {
44
+ const err = await res.text();
45
+ throw new Error(`Microsoft Graph API ${res.status}: ${err}`);
46
+ }
47
+
48
+ return res.json();
49
+ }
50
+
51
+ /**
52
+ * List messages matching a query.
53
+ * @param {object} config
54
+ * @param {string} query — Microsoft Graph $search or $filter compatible string.
55
+ * Supports natural language search (e.g., "from:boss@company.com subject:report").
56
+ * @param {number} maxResults — max messages (default 20)
57
+ * @returns {Promise<Array<object>>} parsed messages
58
+ */
59
+ export async function listMessages(config, query = '', maxResults = 20) {
60
+ const params = new URLSearchParams({
61
+ '$top': String(maxResults),
62
+ '$orderby': 'receivedDateTime desc',
63
+ '$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
64
+ });
65
+
66
+ // Microsoft Graph $search uses KQL (Keyword Query Language)
67
+ if (query) {
68
+ params.set('$search', `"${query}"`);
69
+ }
70
+
71
+ const data = await graphFetch(config, `/messages?${params}`);
72
+ return (data.value || []).map(parseMessage);
73
+ }
74
+
75
+ /**
76
+ * Get a full message by ID.
77
+ * @param {object} config
78
+ * @param {string} messageId
79
+ * @returns {Promise<object>} parsed message
80
+ */
81
+ export async function getMessage(config, messageId) {
82
+ const data = await graphFetch(config, `/messages/${messageId}`);
83
+ return parseMessage(data);
84
+ }
85
+
86
+ /**
87
+ * Get unread important emails (for daily planner).
88
+ * @param {object} config
89
+ * @param {number} maxResults
90
+ * @returns {Promise<Array>} parsed messages
91
+ */
92
+ export async function getUnreadImportant(config, maxResults = 30) {
93
+ const params = new URLSearchParams({
94
+ '$top': String(maxResults),
95
+ '$orderby': 'receivedDateTime desc',
96
+ '$filter': 'isRead eq false',
97
+ '$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
98
+ });
99
+
100
+ const data = await graphFetch(config, `/messages?${params}`);
101
+ const messages = (data.value || []).map(parseMessage);
102
+
103
+ // Cache messages locally
104
+ cacheMessages(messages);
105
+ return messages;
106
+ }
107
+
108
+ /**
109
+ * Get emails from today (read + unread).
110
+ */
111
+ export async function getTodayEmails(config, maxResults = 50) {
112
+ const today = new Date();
113
+ today.setHours(0, 0, 0, 0);
114
+ const todayISO = today.toISOString();
115
+
116
+ const params = new URLSearchParams({
117
+ '$top': String(maxResults),
118
+ '$orderby': 'receivedDateTime desc',
119
+ '$filter': `receivedDateTime ge ${todayISO}`,
120
+ '$select': 'id,conversationId,from,toRecipients,ccRecipients,subject,receivedDateTime,bodyPreview,body,isRead,importance,flag,webLink,hasAttachments',
121
+ });
122
+
123
+ const data = await graphFetch(config, `/messages?${params}`);
124
+ const messages = (data.value || []).map(parseMessage);
125
+
126
+ cacheMessages(messages);
127
+ return messages;
128
+ }
129
+
130
+ /**
131
+ * Send an email via Microsoft Graph.
132
+ * @param {object} config
133
+ * @param {string} to — recipient email
134
+ * @param {string} subject
135
+ * @param {string} body — plain text body
136
+ * @param {object} opts — { cc, bcc, replyToMessageId, threadId }
137
+ */
138
+ export async function sendEmail(config, to, subject, body, opts = {}) {
139
+ if (opts.replyToMessageId) {
140
+ // Reply to existing message
141
+ const replyBody = {
142
+ message: {
143
+ toRecipients: [{ emailAddress: { address: to } }],
144
+ },
145
+ comment: body,
146
+ };
147
+ return graphFetch(config, `/messages/${opts.replyToMessageId}/reply`, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify(replyBody),
151
+ });
152
+ }
153
+
154
+ const message = {
155
+ message: {
156
+ subject,
157
+ body: {
158
+ contentType: 'Text',
159
+ content: body,
160
+ },
161
+ toRecipients: to.split(',').map(addr => ({
162
+ emailAddress: { address: addr.trim() },
163
+ })),
164
+ },
165
+ saveToSentItems: true,
166
+ };
167
+
168
+ if (opts.cc) {
169
+ message.message.ccRecipients = opts.cc.split(',').map(addr => ({
170
+ emailAddress: { address: addr.trim() },
171
+ }));
172
+ }
173
+
174
+ if (opts.bcc) {
175
+ message.message.bccRecipients = opts.bcc.split(',').map(addr => ({
176
+ emailAddress: { address: addr.trim() },
177
+ }));
178
+ }
179
+
180
+ return graphFetch(config, '/sendMail', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify(message),
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Create a draft email.
189
+ */
190
+ export async function createDraft(config, to, subject, body) {
191
+ const draft = {
192
+ subject,
193
+ body: {
194
+ contentType: 'Text',
195
+ content: body,
196
+ },
197
+ toRecipients: to.split(',').map(addr => ({
198
+ emailAddress: { address: addr.trim() },
199
+ })),
200
+ };
201
+
202
+ return graphFetch(config, '/messages', {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify(draft),
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Get user's email profile.
211
+ */
212
+ export async function getProfile(config) {
213
+ const data = await graphFetch(config, '');
214
+ return {
215
+ emailAddress: data.mail || data.userPrincipalName || '',
216
+ displayName: data.displayName || '',
217
+ id: data.id || '',
218
+ };
219
+ }
220
+
221
+ // ── Message Parser ─────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Parse Microsoft Graph message to unified format matching Gmail parser output.
225
+ */
226
+ function parseMessage(raw) {
227
+ const from = raw.from?.emailAddress
228
+ ? `${raw.from.emailAddress.name || ''} <${raw.from.emailAddress.address}>`
229
+ : '';
230
+
231
+ const to = (raw.toRecipients || [])
232
+ .map(r => `${r.emailAddress?.name || ''} <${r.emailAddress?.address}>`)
233
+ .join(', ');
234
+
235
+ // Extract plain text body (Graph returns HTML by default)
236
+ let body = '';
237
+ if (raw.body?.content) {
238
+ if (raw.body.contentType === 'text') {
239
+ body = raw.body.content;
240
+ } else {
241
+ // Strip HTML tags for plain text representation
242
+ body = raw.body.content
243
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
244
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
245
+ .replace(/<[^>]+>/g, ' ')
246
+ .replace(/&nbsp;/g, ' ')
247
+ .replace(/&amp;/g, '&')
248
+ .replace(/&lt;/g, '<')
249
+ .replace(/&gt;/g, '>')
250
+ .replace(/&quot;/g, '"')
251
+ .replace(/&#39;/g, "'")
252
+ .replace(/\s+/g, ' ')
253
+ .trim();
254
+ }
255
+ }
256
+
257
+ // Extract URLs from body
258
+ const urls = (body.match(/https?:\/\/[^\s<>"']+/g) || []).slice(0, 10);
259
+
260
+ // Map Microsoft importance to labels
261
+ const labels = [];
262
+ if (!raw.isRead) labels.push('UNREAD');
263
+ if (raw.importance === 'high') labels.push('IMPORTANT');
264
+ if (raw.flag?.flagStatus === 'flagged') labels.push('FLAGGED');
265
+
266
+ return {
267
+ id: raw.id,
268
+ threadId: raw.conversationId || raw.id,
269
+ from,
270
+ to,
271
+ subject: raw.subject || '(no subject)',
272
+ date: raw.receivedDateTime || '',
273
+ snippet: (raw.bodyPreview || '').slice(0, 200),
274
+ body: body.slice(0, 5000), // cap for LLM context
275
+ urls,
276
+ labels,
277
+ isUnread: !raw.isRead,
278
+ isImportant: raw.importance === 'high',
279
+ sizeEstimate: 0, // Graph API doesn't expose this directly
280
+ provider: 'microsoft',
281
+ };
282
+ }
283
+
284
+ // ── Local Cache ────────────────────────────────────────────────────────────
285
+
286
+ function cacheMessages(messages) {
287
+ fs.mkdirSync(INBOX_DIR, { recursive: true });
288
+ for (const msg of messages) {
289
+ const safeId = msg.id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120);
290
+ const file = path.join(INBOX_DIR, `ms_${safeId}.json`);
291
+ fs.writeFileSync(file, JSON.stringify(msg, null, 2), { mode: 0o600 });
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Load cached messages from disk.
297
+ * @returns {Array} cached messages
298
+ */
299
+ export function loadCachedMessages() {
300
+ if (!fs.existsSync(INBOX_DIR)) return [];
301
+ return fs.readdirSync(INBOX_DIR)
302
+ .filter(f => f.startsWith('ms_') && f.endsWith('.json'))
303
+ .map(f => {
304
+ try { return JSON.parse(fs.readFileSync(path.join(INBOX_DIR, f), 'utf-8')); }
305
+ catch { return null; }
306
+ })
307
+ .filter(Boolean);
308
+ }