1/** 2 * OneDrive operations via Microsoft Graph API. 3 * 4 * Uses the lightweight graphGet/graphPost wrapper (no MS Graph SDK). 5 * All responses use $select to minimize payload size. 6 * 7 * Usage: 8 * import { oneDrive } from '../graph/onedrive.js'; 9 * const items = await oneDrive.getRoot(); 10 * const folder = await oneDrive.getFolder(folderId); 11 */ 12 13import { graphGet, graphPost, graphPutBinary, graphGetBlob } from './graph-client.js'; 14import { fileScopes } from './msal-config.js'; 15 16const SELECT_ITEMS = 'id,name,size,folder,file,lastModifiedDateTime,webUrl,@microsoft.graph.downloadUrl'; 17 18/** Graph rejects a simple PUT at or above 4MB â anything bigger needs an upload session. */ 19const SIMPLE_UPLOAD_LIMIT = 4 * 1024 * 1024; 20 21/** Upload-session chunks MUST be a multiple of 320 KiB. 16 Ã 320 KiB = 5 MiB. */ 22const CHUNK_SIZE = 327_680 * 16; 23 24export const oneDrive = { 25 26 /** 27 * Get root drive contents. 28 * @returns {Array} Mapped drive items 29 */ 30 async getRoot() { 31 const response = await graphGet(`/me/drive/root/children?$select=${SELECT_ITEMS}&$orderby=name`); 32 return (response?.value ?? []).map(mapItem); 33 }, 34 35 /** 36 * Get folder contents with pagination support. 37 * @param {string} folderId 38 * @returns {{ items: Array, nextLink: string|null }} 39 */ 40 async getFolder(folderId) { 41 const response = await graphGet(`/me/drive/items/${folderId}/children?$select=${SELECT_ITEMS}&$orderby=name`); 42 return { 43 items: (response?.value ?? []).map(mapItem), 44 nextLink: response?.['@odata.nextLink'] ?? null, 45 }; 46 }, 47 48 /** 49 * Get a direct download URL for a file. 50 * Uses @microsoft.graph.downloadUrl (pre-authenticated, short-lived). 51 * @param {string} itemId 52 * @returns {{ url: string|null, name: string, size: number }} 53 */ 54 async getDownloadUrl(itemId) { 55 // Don't use $select â @microsoft.graph.downloadUrl is a computed annotation 56 // that some tenants strip when $select is present 57 const item = await graphGet(`/me/drive/items/${itemId}`); 58 return { 59 url: item?.['@microsoft.graph.downloadUrl'] ?? null, 60 name: item?.name ?? '', 61 size: item?.size ?? 0, 62 }; 63 }, 64 65 /** 66 * Download a file (triggers browser download via hidden anchor). 67 * 68 * The pre-auth URL is cross-origin (SharePoint / 1drv), so the `download` 69 * attribute is IGNORED by the browser â it is only honoured same-origin. 70 * Without target="_blank" the click therefore NAVIGATES the current tab to 71 * the file, which tears down whatever panel the user was in. SharePoint 72 * serves these URLs with Content-Disposition: attachment, so the blank tab 73 * closes itself and the download proceeds; anything rendered inline opens in 74 * the new tab instead of destroying the page. `download` is kept for the 75 * same-origin case and as a filename hint. 76 * 77 * @param {string} itemId 78 */ 79 async download(itemId) { 80 const { url, name } = await this.getDownloadUrl(itemId); 81 if (!url) throw new Error('Could not get download URL'); 82 83 const link = document.createElement('a'); 84 link.href = url;
85 link.download = name; 86 link.target = '_blank'; 87 link.rel = 'noopener'; 88 document.body.appendChild(link); 89 link.click(); 90 link.remove(); 91 }, 92 93 /** 94 * Get breadcrumbs for a folder (single API call, parses parentReference.path). 95 * @param {string} folderId 96 * @returns {Array<{ id?: string, name: string }>} 97 */ 98 async getBreadcrumbs(folderId) { 99 const item = await graphGet(`/me/drive/items/${folderId}?$select=id,name,parentReference`); 100 101 const pathStr = item?.parentReference?.path ?? ''; 102 const segments = pathStr.split('/').filter(Boolean); 103 const driveIdx = segments.findIndex(s => s === 'root:'); 104 const folderNames = segments.slice(driveIdx + 1).map(decodeURIComponent); 105 106 const crumbs = folderNames.map(name => ({ name })); 107 crumbs.push({ id: item.id, name: item.name }); 108 return crumbs; 109 }, 110 111 /** 112 * Search files in OneDrive. 113 * @param {string} query 114 * @returns {Array} Matching items 115 */ 116 async search(query) { 117 const response = await graphGet( 118 `/me/drive/root/search(q='${encodeURIComponent(query)}')?$select=id,name,size,folder,file,webUrl,parentReference,lastModifiedDateTime&$top=25` 119 ); 120 return (response?.value ?? []).map(mapItem); 121 }, 122 123 /** 124 * Get the web URL to open a file in OneDrive online. 125 * @param {string} itemId 126 * @returns {string|null} 127 */ 128 async getWebUrl(itemId) { 129 const item = await graphGet(`/me/drive/items/${itemId}?$select=webUrl`); 130 return item?.webUrl ?? null; 131 }, 132 133 /** 134 * Create a sharing link for a file. 135 * @param {string} itemId 136 * @param {'view'|'edit'} type 137 * @returns {{ webUrl: string }} 138 */ 139 async createSharingLink(itemId, type = 'view') { 140 const result = await graphPost(`/me/drive/items/${itemId}/createLink`, { 141 type, 142 scope: 'organization', 143 }); 144 return result?.link ?? {}; 145 }, 146 147 // âââ Write operations âââââââââââââââââââââââââââââââââââ 148 149 /** 150 * Get-or-create a folder directly under the drive root. 151 * 152 * Path-addressed uploads (`/root:/{folder}/{file}:/content`) do NOT create missing 153 * parents â they 404 â so anything uploading into a named folder must call this first. 154 * 155 * @param {string} name â folder name (not a path) 156 * @returns {Promise<object>} the driveItem for the folder 157 */ 158 async ensureFolder(name) { 159 const encoded = encodeURIComponent(name); 160 161 try { 162 const existing = await graphGet(`/me/drive/root:/${encoded}?$select=id,name`); 163 if (existing?.id) return existing; 164 } catch (err) { 165 if (err?.status !== 404) throw err; 166 } 167 168 try { 169 return await graphPost('/me/drive/root/children', { 170 name, 171 folder: {}, 172 '@microsoft.graph.conflictBehavior': 'fail', 173 }); 174 } catch (err) { 175 // Someone (or another tab) created it between our GET and our POST. 176 if (err?.status === 409) return graphGet(`/me/drive/root:/${encoded}?$select=id,name`); 177 throw err; 178 } 179 }, 180 181 /** 182 * Upload a file into a folder under the drive root. 183 * Picks simple PUT vs chunked upload session based on size. 184 * 185 * @param {string} folderName â folder directly under root (created by ensureFolder) 186 * @param {File|Blob} file 187 * @param {{ onProgress?: (pct:number) => void }} [options] 188 * onProgress only fires for the chunked path â a simple PUT is a single 189 * request with no progress events (fetch can't report upload progress). 190 * @returns {Promise<object>} the created driveItem (has id, name, webUrl, eTag) 191 */ 192 async uploadFile(folderName, file, { onProgress } = {}) { 193 const itemPath = `${encodeURIComponent(folderName)}/${encodeURIComponent(file.name)}`; 194 195 if (file.size < SIMPLE_UPLOAD_LIMIT) { 196 return graphPutBinary( 197 `/me/drive/root:/${itemPath}:/[email protected]=rename`, 198 file 199 ); 200 } 201 202 const session = await graphPost(`/me/drive/root:/${itemPath}:/createUploadSession`, { 203 item: { 204 '@microsoft.graph.conflictBehavior': 'rename', 205 name: file.name, 206 }, 207 }); 208 209 if (!session?.uploadUrl) throw new Error('Could not start upload session'); 210 return uploadChunks(session.uploadUrl, file, onProgress); 211 }, 212 213 /** 214 * Grant specific people access to an item â the same call the Teams client makes 215 * when you attach a file to a chat. 216 * 217 * @param {string} itemId 218 * @param {string[]} emails 219 * @param {{ roles?: string[], requireSignIn?: boolean, sendInvitation?: boolean, message?: string }} [options] 220 * @returns {Promise<object|null>} null when there was nobody to invite 221 */ 222 async invite(itemId, emails, options = {}) { 223 const { 224 roles = ['read'], 225 requireSignIn = true, 226 sendInvitation = false, 227 message = '', 228 } = options; 229
230 const recipients = [...new Set((emails ?? []).filter(Boolean))].map(email => ({ email })); 231 if (!recipients.length) return null; 232 233 return graphPost(`/me/drive/items/${itemId}/invite`, { 234 recipients, 235 message, 236 requireSignIn, 237 sendInvitation, 238 roles, 239 }); 240 }, 241 242 // âââ Shared items (files that live in someone ELSE's drive) ââââââââââ 243 244 /** 245 * Resolve a sharing/web URL to the driveItem behind it. 246 * 247 * This is how a file we only know by URL becomes actionable. A Teams chat attachment is a 248 * `reference` pointing at `contentUrl` â a SharePoint/OneDrive web URL â and carries no 249 * driveItem id, no MIME type and no size. One call here supplies all three. 250 * 251 * Do NOT reach for getDownloadUrl/download instead: those are hardcoded to `/me/drive`, 252 * and a file someone sent US lives in THEIR drive. Use the returned 253 * { driveId, id } with `/drives/{driveId}/items/{id}/...` for any follow-up call. 254 * 255 * Read access comes from the share itself (the sender invited us â that's what 256 * `invite()` above does on our own send path). Without it Graph answers 403/404. 257 * 258 * @param {string} shareUrl â the sharing/web URL (e.g. a Teams attachment's contentUrl) 259 * @returns {Promise<{ id, name, size, mimeType, driveId, downloadUrl, webUrl }>} 260 */ 261 async resolveSharedItem(shareUrl) { 262 if (!shareUrl) throw new Error('No share URL provided'); 263 264 // Deliberately no $select â @microsoft.graph.downloadUrl is a computed annotation that 265 // some tenants strip when $select is present (same reason as getDownloadUrl above). 266 // '/shares' isn't classified by graph-client's getScopesForPath, so it would fall 267 // through to loginScopes and fail on privileges â pass the scopes explicitly. 268 const item = await graphGet(`/shares/${encodeSharingToken(shareUrl)}/driveItem`, { 269 scopes: fileScopes, 270 }); 271 272 return { 273 id: item?.id ?? null, 274 name: item?.name ?? '', 275 size: item?.size ?? 0, 276 mimeType: item?.file?.mimeType ?? null, 277 driveId: item?.parentReference?.driveId ?? null, 278 downloadUrl: item?.['@microsoft.graph.downloadUrl'] ?? null, 279 webUrl: item?.webUrl ?? null, 280 }; 281 }, 282 283 /** 284 * Fetch the bytes of an item in ANY drive (not just /me/drive). 285 * @param {string} driveId @param {string} itemId 286 * @returns {Promise<Blob>} 287 */ 288 async getItemBlob(driveId, itemId) { 289 return graphGetBlob(`/drives/${driveId}/items/${itemId}/content`); 290 }, 291}; 292 293/** 294 * Encode a URL as a Graph sharing token: `u!` + unpadded base64url. 295 * 296 * `btoa` alone throws on any character above U+00FF, and SharePoint URLs can carry 297 * un-percent-encoded unicode filenames â so go through UTF-8 bytes first. 298 */ 299function encodeSharingToken(shareUrl) { 300 const bytes = new TextEncoder().encode(shareUrl); 301 let binary = ''; 302 for (const byte of bytes) binary += String.fromCharCode(byte); 303 const b64 = btoa(binary).replace(/=+$/, '').replace(/\//g, '_').replace(/\+/g, '-'); 304 return `u!${b64}`; 305} 306 307/** 308 * PUT a file to an upload session in 5 MiB chunks. 309 * 310 * â ï¸ uploadUrl is PRE-AUTHENTICATED. Do NOT send an Authorization header and do NOT route 311 * this through graph-client â Microsoft rejects the request if a bearer token is attached. 312 * That's why this uses a bare fetch. 313 * 314 * Intermediate chunks answer 202; the last one answers 200/201 with the finished driveItem. 315 */ 316async function uploadChunks(uploadUrl, file, onProgress) { 317 let start = 0; 318 319 while (start < file.size) { 320 const end = Math.min(start + CHUNK_SIZE, file.size); 321 322 const response = await fetch(uploadUrl, { 323 method: 'PUT', 324 headers: { 'Content-Range': `bytes ${start}-${end - 1}/${file.size}` }, 325 body: file.slice(start, end), 326 }); 327 328 if (!response.ok) { 329 let detail = ''; 330 try { detail = (await response.json())?.error?.message ?? ''; } catch { /* ignore */ } 331 throw new Error(`Upload failed at byte ${start}: ${response.status} ${detail || response.statusText}`); 332 } 333 334 if (response.status === 200 || response.status === 201) { 335 onProgress?.(100); 336 return response.json(); 337 } 338 339 start = end; 340 onProgress?.(Math.round((start / file.size) * 100)); 341 } 342 343 throw new Error('Upload session ended without returning a file'); 344} 345 346// âââ Mapper âââââââââââââââââââââââââââââââââââââââââââââ 347 348function mapItem(item) { 349 return { 350 id: item.id, 351 name: item.name ?? '', 352 size: item.size ?? 0, 353 isFolder: !!item.folder, 354 childCount: item.folder?.childCount ?? 0, 355 mimeType: item.file?.mimeType ?? null, 356 lastModified: item.lastModifiedDateTime, 357 webUrl: item.webUrl ?? null, 358 downloadUrl: item['@microsoft.graph.downloadUrl'] ?? null, 359 }; 360}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.