m365-cli 0.1.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/README.md +683 -0
- package/bin/m365.js +489 -0
- package/config/default.json +18 -0
- package/package.json +36 -0
- package/src/auth/device-flow.js +154 -0
- package/src/auth/token-manager.js +237 -0
- package/src/commands/calendar.js +279 -0
- package/src/commands/mail.js +353 -0
- package/src/commands/onedrive.js +423 -0
- package/src/commands/sharepoint.js +312 -0
- package/src/graph/client.js +875 -0
- package/src/utils/config.js +60 -0
- package/src/utils/error.js +114 -0
- package/src/utils/output.js +850 -0
- package/src/utils/trusted-senders.js +190 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import { getAccessToken } from '../auth/token-manager.js';
|
|
2
|
+
import config from '../utils/config.js';
|
|
3
|
+
import { ApiError, parseGraphError } from '../utils/error.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Microsoft Graph API Client
|
|
7
|
+
* Handles HTTP requests with automatic token refresh
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class GraphClient {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.baseUrl = config.get('graphApiUrl');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Make authenticated request
|
|
17
|
+
*/
|
|
18
|
+
async request(endpoint, options = {}) {
|
|
19
|
+
const {
|
|
20
|
+
method = 'GET',
|
|
21
|
+
body = null,
|
|
22
|
+
headers = {},
|
|
23
|
+
queryParams = {},
|
|
24
|
+
} = options;
|
|
25
|
+
|
|
26
|
+
// Get access token (auto-refresh if needed)
|
|
27
|
+
const token = await getAccessToken();
|
|
28
|
+
|
|
29
|
+
// Build URL with query parameters
|
|
30
|
+
let url = `${this.baseUrl}${endpoint}`;
|
|
31
|
+
if (Object.keys(queryParams).length > 0) {
|
|
32
|
+
const params = new URLSearchParams(queryParams);
|
|
33
|
+
url += `?${params.toString()}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build request headers
|
|
37
|
+
const requestHeaders = {
|
|
38
|
+
'Authorization': `Bearer ${token}`,
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
...headers,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Build request options
|
|
44
|
+
const requestOptions = {
|
|
45
|
+
method,
|
|
46
|
+
headers: requestHeaders,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (body && method !== 'GET') {
|
|
50
|
+
requestOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Make request
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(url, requestOptions);
|
|
56
|
+
|
|
57
|
+
// Handle empty responses (204, etc.)
|
|
58
|
+
if (response.status === 204) {
|
|
59
|
+
return { success: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse JSON response
|
|
63
|
+
const data = await response.json().catch(() => ({}));
|
|
64
|
+
|
|
65
|
+
// Handle errors
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw parseGraphError(data, response.status);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return data;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof ApiError) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
throw new ApiError(`Request failed: ${error.message}`, 0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* GET request
|
|
81
|
+
*/
|
|
82
|
+
async get(endpoint, options = {}) {
|
|
83
|
+
return this.request(endpoint, { ...options, method: 'GET' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST request
|
|
88
|
+
*/
|
|
89
|
+
async post(endpoint, body, options = {}) {
|
|
90
|
+
return this.request(endpoint, { ...options, method: 'POST', body });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* PATCH request
|
|
95
|
+
*/
|
|
96
|
+
async patch(endpoint, body, options = {}) {
|
|
97
|
+
return this.request(endpoint, { ...options, method: 'PATCH', body });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* DELETE request
|
|
102
|
+
*/
|
|
103
|
+
async delete(endpoint, options = {}) {
|
|
104
|
+
return this.request(endpoint, { ...options, method: 'DELETE' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mail endpoints
|
|
109
|
+
*/
|
|
110
|
+
mail = {
|
|
111
|
+
/**
|
|
112
|
+
* Map friendly folder names to Graph API well-known folder names
|
|
113
|
+
*/
|
|
114
|
+
_mapFolderName: (folder) => {
|
|
115
|
+
const folderMap = {
|
|
116
|
+
'inbox': 'inbox',
|
|
117
|
+
'sent': 'sentitems',
|
|
118
|
+
'drafts': 'drafts',
|
|
119
|
+
'deleted': 'deleteditems',
|
|
120
|
+
'junk': 'junkemail',
|
|
121
|
+
'archive': 'archive',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Convert to lowercase for case-insensitive matching
|
|
125
|
+
const lowerFolder = folder.toLowerCase();
|
|
126
|
+
|
|
127
|
+
// Return mapped name if found, otherwise return original (for direct IDs)
|
|
128
|
+
return folderMap[lowerFolder] || folder;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* List messages
|
|
133
|
+
*/
|
|
134
|
+
list: async (options = {}) => {
|
|
135
|
+
const { top = 10, folder = 'inbox', select, orderby } = options;
|
|
136
|
+
|
|
137
|
+
const queryParams = {
|
|
138
|
+
'$top': top,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (select) {
|
|
142
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
143
|
+
} else {
|
|
144
|
+
queryParams['$select'] = 'id,subject,from,receivedDateTime,isRead,hasAttachments';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (orderby) {
|
|
148
|
+
queryParams['$orderby'] = orderby;
|
|
149
|
+
} else {
|
|
150
|
+
queryParams['$orderby'] = 'receivedDateTime desc';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Map friendly folder names to Graph API names
|
|
154
|
+
const mappedFolder = this.mail._mapFolderName(folder);
|
|
155
|
+
|
|
156
|
+
let endpoint = '/me/messages';
|
|
157
|
+
if (mappedFolder && mappedFolder !== 'inbox') {
|
|
158
|
+
endpoint = `/me/mailFolders/${mappedFolder}/messages`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const response = await this.get(endpoint, { queryParams });
|
|
162
|
+
return response.value || [];
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get message by ID
|
|
167
|
+
*/
|
|
168
|
+
get: async (id, options = {}) => {
|
|
169
|
+
const { select, expand } = options;
|
|
170
|
+
|
|
171
|
+
const queryParams = {};
|
|
172
|
+
if (select) {
|
|
173
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Always expand attachments to get attachment info
|
|
177
|
+
if (expand !== false) {
|
|
178
|
+
queryParams['$expand'] = 'attachments';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return this.get(`/me/messages/${id}`, { queryParams });
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Send message
|
|
186
|
+
*/
|
|
187
|
+
send: async (message) => {
|
|
188
|
+
const payload = {
|
|
189
|
+
message,
|
|
190
|
+
saveToSentItems: true,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return this.post('/me/sendMail', payload);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Search messages
|
|
198
|
+
*/
|
|
199
|
+
search: async (query, options = {}) => {
|
|
200
|
+
const { top = 10 } = options;
|
|
201
|
+
|
|
202
|
+
const queryParams = {
|
|
203
|
+
'$search': `"${query}"`,
|
|
204
|
+
'$top': top,
|
|
205
|
+
'$select': 'id,subject,from,receivedDateTime,isRead,hasAttachments',
|
|
206
|
+
// Note: $orderby not supported with $search
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const response = await this.get('/me/messages', {
|
|
210
|
+
queryParams,
|
|
211
|
+
headers: {
|
|
212
|
+
'ConsistencyLevel': 'eventual',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return response.value || [];
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* List attachments for a message
|
|
221
|
+
*/
|
|
222
|
+
attachments: async (id) => {
|
|
223
|
+
const response = await this.get(`/me/messages/${id}/attachments`, {
|
|
224
|
+
queryParams: {
|
|
225
|
+
'$select': 'id,name,size,contentType',
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return response.value || [];
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Download a specific attachment
|
|
234
|
+
*/
|
|
235
|
+
downloadAttachment: async (messageId, attachmentId) => {
|
|
236
|
+
return this.get(`/me/messages/${messageId}/attachments/${attachmentId}`);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Calendar endpoints
|
|
242
|
+
*/
|
|
243
|
+
calendar = {
|
|
244
|
+
/**
|
|
245
|
+
* List calendar events in a time range
|
|
246
|
+
*/
|
|
247
|
+
list: async (options = {}) => {
|
|
248
|
+
const { startDateTime, endDateTime, top = 50, select, orderby } = options;
|
|
249
|
+
|
|
250
|
+
if (!startDateTime || !endDateTime) {
|
|
251
|
+
throw new Error('startDateTime and endDateTime are required');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const queryParams = {
|
|
255
|
+
'startDateTime': startDateTime,
|
|
256
|
+
'endDateTime': endDateTime,
|
|
257
|
+
'$top': top,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (select) {
|
|
261
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
262
|
+
} else {
|
|
263
|
+
queryParams['$select'] = 'id,subject,start,end,location,isAllDay,bodyPreview';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (orderby) {
|
|
267
|
+
queryParams['$orderby'] = orderby;
|
|
268
|
+
} else {
|
|
269
|
+
queryParams['$orderby'] = 'start/dateTime';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const response = await this.get('/me/calendarView', {
|
|
273
|
+
queryParams,
|
|
274
|
+
headers: {
|
|
275
|
+
'Prefer': 'outlook.timezone="Asia/Shanghai"',
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return response.value || [];
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get calendar event by ID
|
|
284
|
+
*/
|
|
285
|
+
get: async (id, options = {}) => {
|
|
286
|
+
const { select } = options;
|
|
287
|
+
|
|
288
|
+
const queryParams = {};
|
|
289
|
+
if (select) {
|
|
290
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return this.get(`/me/events/${id}`, {
|
|
294
|
+
queryParams,
|
|
295
|
+
headers: {
|
|
296
|
+
'Prefer': 'outlook.timezone="Asia/Shanghai"',
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create calendar event
|
|
303
|
+
*/
|
|
304
|
+
create: async (event) => {
|
|
305
|
+
return this.post('/me/events', event, {
|
|
306
|
+
headers: {
|
|
307
|
+
'Prefer': 'outlook.timezone="Asia/Shanghai"',
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Update calendar event
|
|
314
|
+
*/
|
|
315
|
+
update: async (id, updates) => {
|
|
316
|
+
return this.patch(`/me/events/${id}`, updates, {
|
|
317
|
+
headers: {
|
|
318
|
+
'Prefer': 'outlook.timezone="Asia/Shanghai"',
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Delete calendar event
|
|
325
|
+
*/
|
|
326
|
+
delete: async (id) => {
|
|
327
|
+
return this.delete(`/me/events/${id}`);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* SharePoint endpoints
|
|
333
|
+
*/
|
|
334
|
+
sharepoint = {
|
|
335
|
+
/**
|
|
336
|
+
* Parse site URL or ID to get site-id
|
|
337
|
+
* Supports three formats:
|
|
338
|
+
* 1. Graph API ID: "hostname,siteId,webId"
|
|
339
|
+
* 2. Site URL: "hostname:/sites/team"
|
|
340
|
+
* 3. Short path: "/sites/team" (auto-completes hostname)
|
|
341
|
+
*/
|
|
342
|
+
_parseSite: async (site) => {
|
|
343
|
+
// Format 1: Graph API ID (hostname,guid,guid)
|
|
344
|
+
// Example: "contoso.sharepoint.com,8bfb5166-...,ea772c4f-..."
|
|
345
|
+
if (site.includes(',')) {
|
|
346
|
+
return site;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Format 2: Site URL with explicit hostname
|
|
350
|
+
// Example: "contoso.sharepoint.com:/sites/team"
|
|
351
|
+
if (site.includes(':/')) {
|
|
352
|
+
const match = site.match(/^(.+?):\/(.*)/);
|
|
353
|
+
if (!match) {
|
|
354
|
+
throw new Error('Invalid site URL format. Use "hostname:/path"');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const [, hostname, path] = match;
|
|
358
|
+
const endpoint = `/sites/${hostname}:/${path}`;
|
|
359
|
+
|
|
360
|
+
const result = await this.get(endpoint, {
|
|
361
|
+
queryParams: {
|
|
362
|
+
'$select': 'id,name,webUrl',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return result.id;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Format 3: Short path (needs hostname lookup)
|
|
370
|
+
// Example: "/sites/team"
|
|
371
|
+
if (site.startsWith('/')) {
|
|
372
|
+
// Get user's SharePoint hostname from profile
|
|
373
|
+
const profile = await this.get('/me', {
|
|
374
|
+
queryParams: {
|
|
375
|
+
'$select': 'mail',
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Extract tenant from email (e.g., user@contoso.onmicrosoft.com)
|
|
380
|
+
const email = profile.mail;
|
|
381
|
+
if (!email) {
|
|
382
|
+
throw new Error('Cannot determine SharePoint hostname. Use full format: "hostname:/path"');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Extract domain and build SharePoint hostname
|
|
386
|
+
const domain = email.split('@')[1];
|
|
387
|
+
let hostname;
|
|
388
|
+
|
|
389
|
+
if (domain.endsWith('.onmicrosoft.com')) {
|
|
390
|
+
// Convert tenant.onmicrosoft.com to tenant.sharepoint.com
|
|
391
|
+
const tenant = domain.replace('.onmicrosoft.com', '');
|
|
392
|
+
hostname = `${tenant}.sharepoint.com`;
|
|
393
|
+
} else {
|
|
394
|
+
// Custom domain - try standard SharePoint hostname
|
|
395
|
+
const orgName = domain.split('.')[0];
|
|
396
|
+
hostname = `${orgName}.sharepoint.com`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Resolve the site
|
|
400
|
+
const endpoint = `/sites/${hostname}:${site}`;
|
|
401
|
+
|
|
402
|
+
const result = await this.get(endpoint, {
|
|
403
|
+
queryParams: {
|
|
404
|
+
'$select': 'id,name,webUrl',
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return result.id;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Otherwise, assume it's a plain GUID or direct site ID
|
|
412
|
+
return site;
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* List accessible sites
|
|
417
|
+
*/
|
|
418
|
+
sites: async (options = {}) => {
|
|
419
|
+
const { search, top = 50 } = options;
|
|
420
|
+
|
|
421
|
+
let endpoint;
|
|
422
|
+
let queryParams = {
|
|
423
|
+
'$top': top,
|
|
424
|
+
'$select': 'id,name,displayName,webUrl,description',
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
if (search) {
|
|
428
|
+
// Search sites
|
|
429
|
+
endpoint = '/sites';
|
|
430
|
+
queryParams['$search'] = `"${search}"`;
|
|
431
|
+
|
|
432
|
+
const response = await this.get(endpoint, {
|
|
433
|
+
queryParams,
|
|
434
|
+
headers: {
|
|
435
|
+
'ConsistencyLevel': 'eventual',
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
return response.value || [];
|
|
439
|
+
} else {
|
|
440
|
+
// List followed sites
|
|
441
|
+
endpoint = '/me/followedSites';
|
|
442
|
+
const response = await this.get(endpoint, { queryParams });
|
|
443
|
+
return response.value || [];
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* List site lists and document libraries
|
|
449
|
+
*/
|
|
450
|
+
lists: async (site, options = {}) => {
|
|
451
|
+
const { top = 100 } = options;
|
|
452
|
+
|
|
453
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
454
|
+
|
|
455
|
+
const queryParams = {
|
|
456
|
+
'$top': top,
|
|
457
|
+
'$select': 'id,name,displayName,description,webUrl,list',
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const response = await this.get(`/sites/${siteId}/lists`, { queryParams });
|
|
461
|
+
return response.value || [];
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* List items in a list
|
|
466
|
+
*/
|
|
467
|
+
items: async (site, listId, options = {}) => {
|
|
468
|
+
const { top = 100 } = options;
|
|
469
|
+
|
|
470
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
471
|
+
|
|
472
|
+
const queryParams = {
|
|
473
|
+
'$top': top,
|
|
474
|
+
'$expand': 'fields',
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const response = await this.get(`/sites/${siteId}/lists/${listId}/items`, { queryParams });
|
|
478
|
+
return response.value || [];
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* List files in site default document library
|
|
483
|
+
*/
|
|
484
|
+
files: async (site, path = '', options = {}) => {
|
|
485
|
+
const { top = 100 } = options;
|
|
486
|
+
|
|
487
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
488
|
+
|
|
489
|
+
let endpoint;
|
|
490
|
+
if (!path || path === '/' || path === '') {
|
|
491
|
+
endpoint = `/sites/${siteId}/drive/root/children`;
|
|
492
|
+
} else {
|
|
493
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
494
|
+
endpoint = `/sites/${siteId}/drive/root:/${cleanPath}:/children`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const queryParams = {
|
|
498
|
+
'$top': top,
|
|
499
|
+
'$select': 'id,name,size,file,folder,lastModifiedDateTime,webUrl',
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const response = await this.get(endpoint, { queryParams });
|
|
503
|
+
return response.value || [];
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Download file from SharePoint
|
|
508
|
+
*/
|
|
509
|
+
download: async (site, path) => {
|
|
510
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
511
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
512
|
+
const endpoint = `/sites/${siteId}/drive/root:/${cleanPath}:/content`;
|
|
513
|
+
|
|
514
|
+
// Get access token
|
|
515
|
+
const token = await getAccessToken();
|
|
516
|
+
|
|
517
|
+
// Make direct fetch request to get binary content
|
|
518
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
519
|
+
const response = await fetch(url, {
|
|
520
|
+
headers: {
|
|
521
|
+
'Authorization': `Bearer ${token}`,
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
const data = await response.json().catch(() => ({}));
|
|
527
|
+
throw parseGraphError(data, response.status);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return response;
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Upload file to SharePoint (small files < 4MB)
|
|
535
|
+
*/
|
|
536
|
+
upload: async (site, path, content, options = {}) => {
|
|
537
|
+
const { contentType = 'application/octet-stream' } = options;
|
|
538
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
539
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
540
|
+
const endpoint = `/sites/${siteId}/drive/root:/${cleanPath}:/content`;
|
|
541
|
+
|
|
542
|
+
// Get access token
|
|
543
|
+
const token = await getAccessToken();
|
|
544
|
+
|
|
545
|
+
// Make direct fetch request to send binary content
|
|
546
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
547
|
+
const response = await fetch(url, {
|
|
548
|
+
method: 'PUT',
|
|
549
|
+
headers: {
|
|
550
|
+
'Authorization': `Bearer ${token}`,
|
|
551
|
+
'Content-Type': contentType,
|
|
552
|
+
},
|
|
553
|
+
body: content,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
if (!response.ok) {
|
|
557
|
+
const data = await response.json().catch(() => ({}));
|
|
558
|
+
throw parseGraphError(data, response.status);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return response.json();
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Create upload session for large files
|
|
566
|
+
*/
|
|
567
|
+
createUploadSession: async (site, path) => {
|
|
568
|
+
const siteId = await this.sharepoint._parseSite(site);
|
|
569
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
570
|
+
const endpoint = `/sites/${siteId}/drive/root:/${cleanPath}:/createUploadSession`;
|
|
571
|
+
|
|
572
|
+
return this.post(endpoint, {
|
|
573
|
+
item: {
|
|
574
|
+
'@microsoft.graph.conflictBehavior': 'rename',
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Search SharePoint content
|
|
581
|
+
*/
|
|
582
|
+
search: async (query, options = {}) => {
|
|
583
|
+
const { top = 50 } = options;
|
|
584
|
+
|
|
585
|
+
const payload = {
|
|
586
|
+
requests: [
|
|
587
|
+
{
|
|
588
|
+
entityTypes: ['driveItem', 'listItem', 'site'],
|
|
589
|
+
query: {
|
|
590
|
+
queryString: query,
|
|
591
|
+
},
|
|
592
|
+
from: 0,
|
|
593
|
+
size: top,
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const response = await this.post('/search/query', payload);
|
|
599
|
+
|
|
600
|
+
// Extract hits from response
|
|
601
|
+
const hits = response.value?.[0]?.hitsContainers?.[0]?.hits || [];
|
|
602
|
+
return hits.map(hit => hit.resource);
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* OneDrive endpoints
|
|
608
|
+
*/
|
|
609
|
+
onedrive = {
|
|
610
|
+
/**
|
|
611
|
+
* Build path for OneDrive item
|
|
612
|
+
*/
|
|
613
|
+
_buildPath: (path) => {
|
|
614
|
+
if (!path || path === '/' || path === '') {
|
|
615
|
+
return '/me/drive/root/children';
|
|
616
|
+
}
|
|
617
|
+
// Remove leading/trailing slashes
|
|
618
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
619
|
+
return `/me/drive/root:/${cleanPath}:`;
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* List files and folders
|
|
624
|
+
*/
|
|
625
|
+
list: async (path = '', options = {}) => {
|
|
626
|
+
const { top = 100, select } = options;
|
|
627
|
+
|
|
628
|
+
let endpoint;
|
|
629
|
+
if (!path || path === '/' || path === '') {
|
|
630
|
+
endpoint = '/me/drive/root/children';
|
|
631
|
+
} else {
|
|
632
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
633
|
+
endpoint = `/me/drive/root:/${cleanPath}:/children`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const queryParams = {
|
|
637
|
+
'$top': top,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
if (select) {
|
|
641
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
642
|
+
} else {
|
|
643
|
+
queryParams['$select'] = 'id,name,size,file,folder,lastModifiedDateTime,webUrl';
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const response = await this.get(endpoint, { queryParams });
|
|
647
|
+
return response.value || [];
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Get item metadata
|
|
652
|
+
*/
|
|
653
|
+
getMetadata: async (path, options = {}) => {
|
|
654
|
+
const { select } = options;
|
|
655
|
+
|
|
656
|
+
let endpoint;
|
|
657
|
+
if (!path || path === '/' || path === '') {
|
|
658
|
+
endpoint = '/me/drive/root';
|
|
659
|
+
} else {
|
|
660
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
661
|
+
endpoint = `/me/drive/root:/${cleanPath}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const queryParams = {};
|
|
665
|
+
if (select) {
|
|
666
|
+
queryParams['$select'] = Array.isArray(select) ? select.join(',') : select;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return this.get(endpoint, { queryParams });
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Download file content
|
|
674
|
+
*/
|
|
675
|
+
download: async (path) => {
|
|
676
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
677
|
+
const endpoint = `/me/drive/root:/${cleanPath}:/content`;
|
|
678
|
+
|
|
679
|
+
// Get access token
|
|
680
|
+
const token = await getAccessToken();
|
|
681
|
+
|
|
682
|
+
// Make direct fetch request to get binary content
|
|
683
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
684
|
+
const response = await fetch(url, {
|
|
685
|
+
headers: {
|
|
686
|
+
'Authorization': `Bearer ${token}`,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
if (!response.ok) {
|
|
691
|
+
const data = await response.json().catch(() => ({}));
|
|
692
|
+
throw parseGraphError(data, response.status);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return response;
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Upload file (small files < 4MB)
|
|
700
|
+
*/
|
|
701
|
+
upload: async (path, content, options = {}) => {
|
|
702
|
+
const { contentType = 'application/octet-stream' } = options;
|
|
703
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
704
|
+
const endpoint = `/me/drive/root:/${cleanPath}:/content`;
|
|
705
|
+
|
|
706
|
+
// Get access token
|
|
707
|
+
const token = await getAccessToken();
|
|
708
|
+
|
|
709
|
+
// Make direct fetch request to send binary content
|
|
710
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
711
|
+
const response = await fetch(url, {
|
|
712
|
+
method: 'PUT',
|
|
713
|
+
headers: {
|
|
714
|
+
'Authorization': `Bearer ${token}`,
|
|
715
|
+
'Content-Type': contentType,
|
|
716
|
+
},
|
|
717
|
+
body: content,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
if (!response.ok) {
|
|
721
|
+
const data = await response.json().catch(() => ({}));
|
|
722
|
+
throw parseGraphError(data, response.status);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return response.json();
|
|
726
|
+
},
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Create upload session for large files
|
|
730
|
+
*/
|
|
731
|
+
createUploadSession: async (path) => {
|
|
732
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
733
|
+
const endpoint = `/me/drive/root:/${cleanPath}:/createUploadSession`;
|
|
734
|
+
|
|
735
|
+
return this.post(endpoint, {
|
|
736
|
+
item: {
|
|
737
|
+
'@microsoft.graph.conflictBehavior': 'rename',
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Upload chunk to session
|
|
744
|
+
*/
|
|
745
|
+
uploadChunk: async (uploadUrl, chunk, start, end, totalSize) => {
|
|
746
|
+
const response = await fetch(uploadUrl, {
|
|
747
|
+
method: 'PUT',
|
|
748
|
+
headers: {
|
|
749
|
+
'Content-Length': chunk.length.toString(),
|
|
750
|
+
'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`,
|
|
751
|
+
},
|
|
752
|
+
body: chunk,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (!response.ok && response.status !== 202) {
|
|
756
|
+
const data = await response.json().catch(() => ({}));
|
|
757
|
+
throw parseGraphError(data, response.status);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return response.json().catch(() => ({ status: response.status }));
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Search files
|
|
765
|
+
*/
|
|
766
|
+
search: async (query, options = {}) => {
|
|
767
|
+
const { top = 50 } = options;
|
|
768
|
+
|
|
769
|
+
const queryParams = {
|
|
770
|
+
'$top': top,
|
|
771
|
+
'$select': 'id,name,size,file,folder,lastModifiedDateTime,webUrl',
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const endpoint = `/me/drive/root/search(q='${encodeURIComponent(query)}')`;
|
|
775
|
+
const response = await this.get(endpoint, { queryParams });
|
|
776
|
+
return response.value || [];
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Create sharing link
|
|
781
|
+
*/
|
|
782
|
+
share: async (path, options = {}) => {
|
|
783
|
+
const { type = 'view', scope = 'organization' } = options;
|
|
784
|
+
|
|
785
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
786
|
+
const endpoint = `/me/drive/root:/${cleanPath}:/createLink`;
|
|
787
|
+
|
|
788
|
+
return this.post(endpoint, {
|
|
789
|
+
type,
|
|
790
|
+
scope,
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Create folder
|
|
796
|
+
*/
|
|
797
|
+
mkdir: async (path) => {
|
|
798
|
+
const parts = path.split('/').filter(p => p);
|
|
799
|
+
const folderName = parts.pop();
|
|
800
|
+
const parentPath = parts.join('/');
|
|
801
|
+
|
|
802
|
+
let endpoint;
|
|
803
|
+
if (!parentPath) {
|
|
804
|
+
endpoint = '/me/drive/root/children';
|
|
805
|
+
} else {
|
|
806
|
+
endpoint = `/me/drive/root:/${parentPath}:/children`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return this.post(endpoint, {
|
|
810
|
+
name: folderName,
|
|
811
|
+
folder: {},
|
|
812
|
+
'@microsoft.graph.conflictBehavior': 'fail',
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Delete file or folder
|
|
818
|
+
*/
|
|
819
|
+
remove: async (path) => {
|
|
820
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
821
|
+
const endpoint = `/me/drive/root:/${cleanPath}`;
|
|
822
|
+
|
|
823
|
+
return this.delete(endpoint);
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Invite users to access file (external sharing)
|
|
828
|
+
*/
|
|
829
|
+
invite: async (path, options = {}) => {
|
|
830
|
+
const {
|
|
831
|
+
recipients = [],
|
|
832
|
+
role = 'read',
|
|
833
|
+
message = '',
|
|
834
|
+
sendInvitation = true,
|
|
835
|
+
requireSignIn = false,
|
|
836
|
+
} = options;
|
|
837
|
+
|
|
838
|
+
if (!recipients || recipients.length === 0) {
|
|
839
|
+
throw new Error('At least one recipient email is required');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Get item ID first
|
|
843
|
+
const cleanPath = path.replace(/^\/+|\/+$/g, '');
|
|
844
|
+
const itemEndpoint = `/me/drive/root:/${cleanPath}`;
|
|
845
|
+
const item = await this.get(itemEndpoint, {
|
|
846
|
+
queryParams: { '$select': 'id,name' },
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Prepare recipients array
|
|
850
|
+
const recipientsList = recipients.map(email => ({
|
|
851
|
+
email: typeof email === 'string' ? email : email.email,
|
|
852
|
+
}));
|
|
853
|
+
|
|
854
|
+
// Call invite API
|
|
855
|
+
const inviteEndpoint = `/me/drive/items/${item.id}/invite`;
|
|
856
|
+
const payload = {
|
|
857
|
+
requireSignIn,
|
|
858
|
+
sendInvitation,
|
|
859
|
+
roles: [role],
|
|
860
|
+
recipients: recipientsList,
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// Add message if provided
|
|
864
|
+
if (message) {
|
|
865
|
+
payload.message = message;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return this.post(inviteEndpoint, payload);
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Export singleton instance
|
|
874
|
+
const graphClient = new GraphClient();
|
|
875
|
+
export default graphClient;
|