PageSourceSearch

https://smrtbld.ai/js/utils/external-attachment-actions.js

js smrtbld.ai collected 2026-10-05 15:09:00 UTC 27,589 bytes, 608 lines download raw bytes

1/**
2 * External attachment action menu — the chevron dropdown on an attachment chip.
3 *
4 * Six actions, provider-agnostic: Save to Project Folder, Create RFI / Change Mgmt /
5 * Site Instruction / Submittal from the file, and Download.
6 *
7 * Extracted from pages/messages.js so the Teams panel can use the same machinery. It was
8 * private to that page module, and a global component can't import a 6,800-line page module
9 * (or its page-lifecycle AbortController) just to reach four handlers.
10 *
11 * Three providers, one seam. Everything below is source-agnostic EXCEPT `_resolveAttachment`,
12 * which is the only place that knows how a given provider hands over bytes:
13 *
14 *   outlook — Graph mail attachment, base64 `contentBytes` inline
15 *   gmail   — Gmail API attachment, base64url (normalized here)
16 *   teams   — NOT bytes. A `reference` to a driveItem in the SENDER's drive, reached by
17 *             resolving its contentUrl through /shares. See graph/onedrive.js#resolveSharedItem.
18 *
19 * Usage:
20 *   import { showAttachmentActionMenu, closeAttachmentActionMenu } from '../utils/external-attachment-actions.js';
21 *
22 *   showAttachmentActionMenu(
23 *       { source: 'teams', msgId, attachId, filename, contentUrl },
24 *       cardEl,
25 *       { zClass: 'z-popover' },
26 *   );
27 */
28
29import { documentApi } from '../api/project-documents.js';
30import { messagesApi } from '../api/messages.js';
31import { importToProjectFolder } from '../api/onedrive-import.js';
32import { loadTemplates } from './templates.js';
33import { setLoading, afterRender } from './dom.js';
34import { getMicrosoftFileIcon } from './file-icons-microsoft.js';
35import { downloadBlob } from './download.js';
36import { flagFieldError } from './form-validation.js';
37import { showModal } from './modal.js';
38import { createPortalPopover } from './popover-position.js';
39import { SearchableDropdown } from '../components/SearchableDropdown.js';
40
41/** data-action="create-{type}" → the linkType recorded in becc_pm_external_item_links. */
42const CREATE_LINKTYPE = { rfi: 'RFI', chg: 'CHG', si: 'SI', submittal: 'SUBMITTAL' };
43
44/** Human label per provider, for the imported-file description. */
45const SOURCE_LABEL = { outlook: 'Outlook', gmail: 'Gmail', teams: 'Teams' };
46
47// ─── Menu lifecycle ─────────────────────────────────────
48
49let _menuEl = null;
50let _docClickHandler = null;
51let _escHandler = null;
52let _anchorBtn = null;
53/** createPortalPopover's teardown — drops its scroll/resize listeners. */
54let _positionCleanup = null;
55
56/**
57 * Bumped on every open/close. showAttachmentActionMenu awaits a template load before it can
58 * append, so two fast clicks could both get past that await and leave the first menu orphaned
59 * in the DOM with no ref to close it. Whoever isn't the latest opener bails.
60 */
61let _menuGeneration = 0;
62
63/**
64 * Close the dropdown and detach its document listeners.
65 * Safe to call when nothing is open — callers use it as an unconditional teardown.
66 */
67export function closeAttachmentActionMenu() {
68    _menuGeneration++;
69
70    // Detach the popover's scroll/resize listeners BEFORE removing the element — createPortalPopover
71    // binds them to every scrollable ancestor and does not remove the node itself.
72    _positionCleanup?.();
73    _positionCleanup = null;
74
75    _menuEl?.remove();
76    _menuEl = null;
77
78    if (_docClickHandler) {
79        document.removeEventListener('click', _docClickHandler);
80        _docClickHandler = null;
81    }
82    if (_escHandler) {
83        document.removeEventListener('keydown', _escHandler);
84        _escHandler = null;
85    }
86    _anchorBtn?.setAttribute('aria-expanded', 'false');
87    _anchorBtn = null;
88}
89
90/**
91 * Open the attachment action menu anchored to a chip.
92 *
93 * @param {{ source: 'outlook'|'gmail'|'teams', msgId?: string, attachId?: string,
94 *           filename?: string, contentType?: string, contentUrl?: string }} ref
95 *        Attachment identity. Teams identifies a file by `contentUrl` — its `attachId` is an
96 *        eTag GUID that can't fetch anything — so this is an object, not positional args.
97 * @param {HTMLElement} card The chip the menu hangs off (used for positioning + outside-click).
98 * @param {{ zClass?: string, signal?: AbortSignal, createSignal?: AbortSignal,
99 *           canPublish?: () => boolean, canContinueDurable?: () => boolean,
100 *           onLinked?: (source: string, msgId: string) => Promise<void>|void }} [opts]
101 *        zClass   — stacking class. Default 'z-dropdown' (1000). The Teams panel is z-fixed
102 *                   (1035), so a menu over it MUST pass 'z-popover' (1060) or it renders behind.
103 *        signal   — page AbortSignal, where one exists. Components without one rely on
104 *                   closeAttachmentActionMenu() in their own teardown.
105 *        onLinked — called after a per-user link is recorded, to refresh a "Linked to" rail.
106 */
107export async function showAttachmentActionMenu(ref, card, opts = {}) {
108    closeAttachmentActionMenu(); // tear down any prior menu + its listeners first
109    const generation = _menuGeneration;
110
111    // The menu template ships with the messages page's templates; the pickers/folder modal come
112    // from message-action-modals. loadTemplates caches per file, so this is a no-op once warm.
113    await loadTemplates('components/message-sub-templates');
114
115    // A newer open (or a close) happened while we were loading — that one owns the menu now.
116    if (generation !== _menuGeneration || opts.signal?.aborted || opts.canPublish?.() === false) return;
117
118    const {
119        zClass = 'z-dropdown',
120        signal,
121        createSignal,
122        canPublish,
123        canContinueDurable,
124        onLinked,
125    } = opts;
126
127    const menu = document.createElement('div');
128    menu.className = `fixed w-52 bg-theme-primary border border-theme rounded-lg shadow-sb-lg py-1 ${zClass} animate-in fade-in zoom-in duration-150 overflow-y-auto`;
129    menu.setAttribute('role', 'menu');
130    menu.innerHTML = $.templates('#tpl-external-attach-menu').render({});
131
132    // A chip sitting low in the thread had the menu running off the bottom of the screen, because
133    // it only ever opened downward. createPortalPopover flips it above the chip when there isn't
134    // room below, clamps it to the viewport, and keeps it anchored while the thread scrolls.
135    // Never taller than the viewport either — with overflow-y-auto above, it scrolls internally
136    // rather than overflowing on a short window.
137    menu.style.maxHeight = `${window.innerHeight - 24}px`;
138    _positionCleanup = createPortalPopover(card, menu, { offset: 4 });
139
140    afterRender(menu);
141    _menuEl = menu;
142
143    _anchorBtn = card.querySelector('.ext-attach-menu-btn') || null;
144    _anchorBtn?.setAttribute('aria-expanded', 'true');
145
146    menu.addEventListener('click', async (e) => {
147        const btn = e.target.closest('.ext-attach-action');
148        if (!btn) return;
149
150        const action = btn.dataset.action;
151        closeAttachmentActionMenu();
152
153        if (action === 'download') {
154            await handleAttachDownload(ref);
155        } else if (action === 'folder') {
156            await handleAttachImport(ref, { signal, onLinked });
157        } else if (action?.startsWith('create-')) {
158            await handleAttachCreate(ref, action.slice(7), {
159                signal: action === 'create-submittal' ? (createSignal || signal) : signal,
160                canPublish, canContinueDurable, onLinked,
161            });
162        }
163    });
164
165    // Close on outside click / Esc. Both handlers are tracked in module refs and removed on
166    // close, so they never accumulate across opens.
167    _docClickHandler = (e) => {
168        if (!menu.contains(e.target) && !card.contains(e.target)) closeAttachmentActionMenu();
169    };
170    _escHandler = (e) => {
171        if (e.key === 'Escape') closeAttachmentActionMenu();
172    };
173    const listenerOpts = signal ? { signal }
173 : {};
174    document.addEventListener('click', _docClickHandler, listenerOpts);
175    document.addEventListener('keydown', _escHandler, listenerOpts);
176}
177
178// ─── The provider seam ──────────────────────────────────
179
180/**
181 * Resolve an attachment ref into something we can upload.
182 *
183 * Returns a descriptor rather than raw bytes, because the providers differ in kind and not
184 * just in detail: Outlook/Gmail hand over base64 in the response, while a Teams file already
185 * lives in OneDrive and is best moved server-side — the API can stream it straight from the
186 * pre-authenticated URL instead of routing up to 100MB through browser memory.
187 *
188 * @returns {Promise<{ contentType: string, blob?: () => Promise<Blob>, downloadUrl?: string }|null>}
189 *          `blob()` is lazy — the download and create-from flows need bytes; the
190 *          save-to-folder flow prefers `downloadUrl` and never materializes them.
191 */
192async function _resolveAttachment(ref) {
193    const { source, msgId, attachId, contentType, contentUrl } = ref;
194
195    if (source === 'teams') {
196        const { oneDrive } = await import('../graph/onedrive.js');
197        const item = await oneDrive.resolveSharedItem(contentUrl);
198        if (!item?.id || !item?.driveId) return null;
199        return {
200            contentType: item.mimeType || contentType || 'application/octet-stream',
201            downloadUrl: item.downloadUrl,
202            blob: () => oneDrive.getItemBlob(item.driveId, item.id),
203        };
204    }
205
206    if (source === 'outlook') {
207        const { outlookMail } = await import('../graph/outlook-mail.js');
208        const a = await outlookMail.getAttachment(msgId, attachId);
209        if (!a?.contentBytes) return null;
210        const type = a.contentType || contentType || 'application/octet-stream';
211        return { contentType: type, blob: async () => _base64ToBlob(a.contentBytes, type) };
212    }
213
214    if (source === 'gmail') {
215        const { googleAuth } = await import('../google/auth.js');
216        const resp = await googleAuth.callWithTokenRefresh(() =>
217            gapi.client.gmail.users.messages.attachments.get({ userId: 'me', messageId: msgId, id: attachId })
218        );
219        const data = resp?.result?.data;
220        if (!data) return null;
221        // Gmail returns url-safe base64 — normalize to standard before atob.
222        const base64 = data.replace(/-/g, '+').replace(/_/g, '/');
223        const type = contentType || 'application/octet-stream';
224        return { contentType: type, blob: async () => _base64ToBlob(base64, type) };
225    }
226
227    return null;
228}
229
230/** Convert base64 string to a Blob. */
231function _base64ToBlob(base64, contentType) {
232    const byteChars = atob(base64);
233    const byteArrays = [];
234    for (let i = 0; i < byteChars.length; i += 512) {
235        const slice = byteChars.slice(i, i + 512);
236        const bytes = new Uint8Array(slice.length);
237        for (let j = 0; j < slice.length; j++) bytes[j] = slice.charCodeAt(j);
238        byteArrays.push(bytes);
239    }
240    return new Blob(byteArrays, { type: contentType || 'application/octet-stream' });
241}
242
243/**
244 * A Teams file we can't read is the one predictable failure here — the sender may never have
245 * granted us access. Say so specifically instead of "failed to download".
246 */
247function _attachmentErrorMessage(ref, error) {
248    const status = error?.status ?? error?.statusCode;
249    if (ref.source === 'teams' && (status === 403 || status === 404)) {
250        return "You don't have access to this file in OneDrive/SharePoint. Ask the sender to share it.";
251    }
252    return error?.message || 'Could not read the attachment';
253}
254
255// ─── Download ───────────────────────────────────────────
256
257async function handleAttachDownload(ref) {
258    const { source, msgId, attachId, filename, contentType } = ref;
259
260    try {
261        // Outlook and Gmail already have purpose-built download helpers — use them.
262        if (source === 'outlook') {
263            const { downloadAttachment } = await import('../graph/outlook-mail.js');
264            await downloadAttachment(msgId, attachId, filename);
265            return;
266        }
267        if (source === 'gmail') {
268            const { gmailApi } = await import('../google/gmail.js');
269            await gmailApi.downloadAttachment(msgId, attachId, filename, contentType);
270            return;
271        }
272
273        // Teams: fetch through Graph rather than pointing an <a download> at the pre-auth URL —
274        // the download attribute is ignored cross-origin, which navigates instead of saving.
275        const resolved = await _resolveAttachment(ref);
276        if (!resolved?.blob) throw new Error('Could not locate the file');
277        downloadBlob(await resolved.blob(), filename || 'attachment');
278    } catch (error) {
279        if (error.name === 'AbortError') return;
280        console.error('[Attachments] Download failed:', error);
281        window.showToast?.('Download failed', 'error', { description: _attachmentErrorMessage(ref, error) });
282    }
283}
284
285// ─── Save to Project Folder ─────────────────────────────
286
287/**
288 * Project + folder picker, then move the file into the project's Documents.
289 *
290 * @param {object} ref
291 * @param {{ signal?: AbortSignal, onLinked?: Function }} [opts]
292 */
293async function handleAttachImport(ref, opts = {}) {
294    const { signal, onLinked } = opts;
295    const { source, msgId, filename } = ref;
296
297    // message-action-modals carries the folder-picker modal; message-sub-templates carries the
298    // project select, the empty state and the inline spinner/error used below. The menu already
299    // loaded the latter, but don't depend on the caller's route in here.
300    await Promise.all([
301        loadTemplates('components/message-action-modals'),
302        loadTemplates('components/message-sub-templates'),
303    ]);
304
305    const modal = await showModal('#tpl-folder-picker-modal', {
306        fileName: filename || 'File'
307    }, { signal });
308
309    let projectId = null;
310    let currentFolderId = 0;
311
312    // We need a project selector — insert one above the toolbar
313    const toolbar = modal.element.querySelector('.px-5.pt-4.pb-2');
314    if (toolbar) {
315        const projectRow = document.createElement('div');
316        projectRow.className = 'px-5 pt-4 pb-2';
317        projectRow.innerHTML = $.templates('#tpl-import-project-select').render({});
318        toolbar.parentNode.insertBefore(projectRow, toolbar);
319        toolbar.classList.add('hidden');
320
321        // Load projects
322        try {
323            const { ApiClient } = await import('../api/base.js');
324            const api = new ApiClient();
325            const response = await api.get('smrtbuild/projects');
326            const projects = response?.projects || [];
327            const select = projectRow.querySelector('select');
328            for (const proj of projects) {
329                const opt = document.createElement('option');
330                opt.value = proj.projectId;
331                opt.textContent = proj.projectNumber
332                    ? `${proj.projectNumber} — ${proj.projectTitle}`
333                    : (proj.projectTitle || 'Untitled');
334                select.appendChild(opt);
335            }
336            // Enhance after options are loaded
337            SearchableDropdown.enhanceAll(projectRow);
338        } catch (err) {
339            if (err.name === 'AbortError') return;
340            console.error('[Attachments] Failed to load projects:', err);
341        }
342    }
343
344    const breadcrumbsEl = modal.element.querySelector('#fp-breadcrumbs');
345    const itemsEl = modal.element.querySelector('#fp-items');
346    const saveBtn = modal.element.querySelector('#btn-fp-save-here');
347    const newFolderBtn = modal.element.querySelector('#btn-fp-new-folder');
348    const newFolderRow = modal.element.querySelector('#fp-new-folder-row');
349    const newFolderInput = modal.element.querySelector('#fp-new-folder-input');
350    const confirmFolderBtn = modal.element.querySelector('#btn-fp-confirm-folder');
351    const cancelFolderBtn = modal.element.querySelector('#btn-fp-cancel-folder');
352
353    // Show placeholder
354    if (itemsEl) {
355        itemsEl.innerHTML = $.templates('#tpl-folder-picker-empty').render({});
356        afterRender(itemsEl);
357    }
358
359    async function loadFolder(folderId) {
360        currentFolderId = folderId;
361        itemsEl.innerHTML = $.templates('#tpl-inline-loading-spinner').render({});
362
363        try {
364            const data = await documentApi.getFolderContents(projectId, folderId);
365            const bcTmpl = $.templates('#tpl-fp-breadcrumbs');
366            breadcrumbsEl.innerHTML = bcTmpl.render({ breadcrumbs: data.breadcrumbs || [] });
367            afterRender(breadcrumbsEl);
368
369            const items = [...(data.items || [])].sort((a, b) => {
370                if (a.type !== b.type) return a.type === 'F' ? -1 : 1;
371                return (a.name || '').localeCompare(b.name || '');
372            }).map(item => ({
373                ...item,
374                icon: item.type === 'F' ? 'folder' : getMicrosoftFileIcon(item.fileName)
375            }));
376
377            const itemsTmpl = $.templates('#tpl-fp-items');
378            itemsEl.innerHTML = itemsTmpl.render({ items });
379            afterRender(itemsEl);
380        } catch (error) {
381            if (error.name === 'AbortError') return;
382            itemsEl.innerHTML = $.templates('#tpl-inline-error').render({ message: 'Failed to load folders' });
383        }
384
385        newFolderRow.classList.add('hidden');
386    }
387
388    // Project selection
389    modal.element.querySelector('#ol-import-project')?.addEventListener('change', (e) => {
390        const id = parseInt(e.target.value);
391        if (id > 0) {
392            projectId = id;
393            toolbar?.classList.remove('hidden');
394            loadFolder(0);
395        } else {
396            projectId = null;
397            toolbar?.classList.add('hidden');
398        }
399    });
400
401    // Folder navigation
402    itemsEl.addEventListener('click', (e) => {
403        const folder = e.target.closest('[data-folder-id]');
404        if (folder) loadFolder(parseInt(folder.dataset.folderId));
405    });
406
407    breadcrumbsEl.addEventListener('click', (e) => {
408        const btn = e.target.closest('.fp-breadcrumb-btn');
409        if (btn) loadFolder(parseInt(btn.dataset.folderId) || 0);
410    });
411
412    // New folder
413    newFolderBtn.addEventListener('click', () => {
414        newFolderRow.classList.remove('hidden');
415        newFolderInput.value = '';
416        newFolderInput.focus();
417    });
418
419    cancelFolderBtn.addEventListener('click', () => {
420        newFolderRow.classList.add('hidden');
421    });
422
423    async function createFolder() {
424        const name = newFolderInput.value.trim();
425        if (!name) { newFolderInput.classList.add('border-sb-error'); return; }
426        newFolderInput.classList.remove('border-sb-error');
427        try {
428            setLoading(confirmFolderBtn, true);
429            await documentApi.createFolder(projectId, { name, parentFolderId: currentFolderId });
430            newFolderRow.classList.add('hidden');
431            await loadFolder(currentFolderId);
432        } catch (error) {
433            if (error.name === 'AbortError') return;
434            window.showToast?.('Failed to create folder', 'error', { description: error.message });
435            setLoading(confirmFolderBtn, false);
436        }
437    }
438
439    confirmFolderBtn.addEventListener('click', createFolder);
440    newFolderInput.addEventListener('keydown', (e) => {
441        if (e.key === 'Enter') createFolder();
442        if (e.key === 'Escape') newFolderRow.classList.add('hidden');
443    });
444
445    // Save Here — pull the attachment from the provider, then land it in the project folder
446    saveBtn.addEventListener('click', async () => {
447        if (!projectId) return;
448        setLoading(saveBtn, true, 'Saving...');
449        try {
450            const description = `Imported from ${SOURCE_LABEL[source] || 'Outlook'}`;
451            const resolved = await _resolveAttachment(ref);
452            if (!resolved) throw new Error('Could not download attachment');
453
454            if (resolved.downloadUrl) {
455                // The file already lives in OneDrive — hand the API its pre-authenticated URL and
456                // let the server stream the bytes. Nothing large passes through the browser.
457                await importToProjectFolder(resolved.downloadUrl, filename, currentFolderId, projectId, { description });
458            } else {
459                const formData = new FormData();
460                formData.append('file', await resolved.blob(), filename);
461                formData.append('folderId', currentFolderId);
462                formData.append('description', description);
463                await documentApi.uploadFile(projectId, formData);
464            }
465
466            // Record the save (per-user link to the project's Documents area) so the user sees it
467            // landed and can jump back. Best-effort — never block the save.
468            try {
469                await messagesApi.createExternalLink({ source, externalId: msgId, projectId, linkType: 'PROJECTDOC', itemId: currentFolderId });
470                await onLinked?.(source, msgId);
471            } catch (linkErr) {
472                console.warn('[Attachments] Failed to record save-to-folder link:', linkErr);
473            }
474
475            modal.close();
476            window.showToast?.('Saved to project folder', 'success', { description: filename });
477        } catch (error) {
478            if (error.name === 'AbortError') return;
479            window.showToast?.('Failed to import file', 'error', { description: _attachmentErrorMessage(ref, error) });
480            setLoading(saveBtn, false);
481        }
482    });
483}
484
485// ─── Create from attachment ─────────────────────────────
486
487/**
488 * Show a minimal project picker modal and resolve with the selected projectId (or null if dismissed).
489 * Exported because the message-level Create/Link menu in messages.js uses the same picker.
490 */
491export async function pickAttachmentProject(signal) {
492    await loadTemplates('components/message-sub-templates');
493
494    // Hoisted deferred (the utils/email-compose.js:106-110 shape), settled exactly
495    // once by every exit. Dismissal resolution rides showModal's onClose rather than
496    // a duplicate listener on the funnel's own buttons, so a dismissal the
497    // unsaved-input guard refuses or defers cannot resolve the caller while the
498    // picker is still open.
499    let settle = () => {};
500    const settled = new Promise((resolve) => {
501        let done = false;
502        settle = (value) => { if (!done) { done = true; resolve(value); } };
503    });
504
505    const modal = await showModal('#tpl-outlook-project-picker-modal', {}, {
506        signal,
507        onClose: () => settle(null),
508    });
509
510    const select = modal.element.querySelector('#ol-picker-project');
511    const confirmBtn = modal.element.querySelector('#btn-ol-picker-confirm');
512
513    try {
514        const { ApiClient } = await import('../api/base.js');
515        const api = new ApiClient();
516        const response = await api.get('smrtbuild/projects', signal ? { signal }
516 : {});
517        const projects = response?.projects || [];
518        for (const proj of projects) {
519            const opt = document.createElement('option');
520            opt.value = proj.projectId;
521            opt.textContent = proj.projectNumber
522                ? `${proj.projectNumber} — ${proj.projectTitle}`
523                : (proj.projectTitle || 'Untitled');
524            select.appendChild(opt);
525        }
526        SearchableDropdown.enhanceAll(modal.element);
527    } catch (err) {
528        if (err.name === 'AbortError') return null;
529        console.error('[Attachments] Failed to load projects for picker:', err);
530    }
531
532    confirmBtn?.addEventListener('click', () => {
533        const projectId = parseInt(select?.value);
534        if (!projectId) {
535            flagFieldError(modal.element, select, 'Project is required', { clearOn: 'change' });
536            return;
537        }
538        // settle BEFORE close — first settle wins over onClose's null.
539        settle(projectId);
540        modal.close();
541    }, signal ? { signal } : {});
542
543    return settled;
544}
545
546/**
547 * Create a new record (RFI / CHG / SI / Submittal) from an attachment.
548 * Pulls the bytes, then opens the native create-and-link modal with the file pre-loaded —
549 * no import step. `type` = rfi|chg|si|submittal.
550 */
551async function handleAttachCreate(ref, type, opts = {}) {
552    const { signal, onLinked } = opts;
553    const { source, msgId, filename, contentType } = ref;
554    const isOwnedSubmittal = type === 'submittal';
555    const canPublish = opts.canPublish || (() => true);
556    const canContinueDurable = opts.canContinueDurable || (() => true);
557    if (isOwnedSubmittal && (!canPublish() || !canContinueDurable())) return;
558
559    const projectId = await pickAttachmentProject(signal);
560    if (!projectId) return;
561    if (isOwnedSubmittal && (!canPublish() || !canContinueDurable())) return;
562
563    let staged;
564    try {
565        const resolved = await _resolveAttachment(ref);
566        if (!resolved?.blob) throw new Error('Could not download attachment');
567        const blob = await resolved.blob();
568        if (isOwnedSubmittal && (!canPublish() || !canContinueDurable())) return;
569        staged = isOwnedSubmittal
570            ? { blob, contentType: resolved.contentType }
571            : { url: URL.createObjectURL(blob), contentType: resolved.contentType };
572    } catch (error) {
573        if (error.name === 'AbortError' || (isOwnedSubmittal && !canPublish())) return;
574        console.error('[Attachments] Failed to stage attachment:', error);
575        window.showToast?.('Failed to download attachment', 'error', { description: _attachmentErrorMessage(ref, error) });
576        return;
577    }
578
579    const createOpts = {
580        signal,
581        attachment: {
582            fileName: filename,
583            contentType: staged.contentType || contentType || 'application/octet-stream',
584            ...(isOwnedSubmittal ? { blob: staged.blob } : { url: staged.url }),
585        },
586        // Record the created record as a per-user link so it shows in "Linked to".
587        onCreated: async (itemId) => {
588            // Other record types stage a blob: URL, which cannot be revoked when the modal is
589            // merely wired; the upload has not run yet. Submittal keeps the Blob directly so
590            // its dismissed form has no object URL to leak.
591            if (staged.url) URL.revokeObjectURL(staged.url);
592            if (isOwnedSubmittal && !canContinueDurable()) return false;
593            await messagesApi.createExternalLink({
594                source, externalId: msgId, projectId, linkType: CREATE_LINKTYPE[type], itemId,
595            });
596            if (isOwnedSubmittal && !canContinueDurable()) return false;
597            if (!isOwnedSubmittal || canPublish()) await onLinked?.(source, msgId);
598            return !isOwnedSubmittal || canContinueDurable();
599        },
600    };
601    if (isOwnedSubmittal) Object.assign(createOpts, { canPublish, canContinueDurable });
602    const clm = await import('../pages/message-create-link.js');
603    if (isOwnedSubmittal && (!canPublish() || !canContinueDurable())) return;
604    if (type === 'rfi') await clm.showCreateRfiAndLink(projectId, null, createOpts);
605    else if (type === 'chg') await clm.showCreateChgAndLink(projectId, null, createOpts);
606    else if (type === 'si') await clm.showCreateSiAndLink(projectId, null, createOpts);
607    else if (type === 'submittal') await clm.showCreateSubmittalAndLink(projectId, null, createOpts);
608}

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.