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.