PageSourceSearch

https://smrtbld.ai/js/graph/onedrive.js

js smrtbld.ai collected 2026-10-05 15:09:03 UTC 13,747 bytes, 360 lines download raw bytes

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.