1/** 2 * Markups API Layer â Pure functions, no DOM, no side effects. 3 * 4 * Backend: MarkupsController.cs (legacy port, routes: api/markups/...) 5 * All responses auto-transformed to camelCase by ApiClient. 6 */ 7import { ApiClient } from './base.js'; 8import { auth } from '../auth.js'; 9import { config } from '../config.js'; 10import { postFormDataWithProgress } from '../utils/xhr-upload.js'; 11 12const api = new ApiClient(); 13 14function withIdempotencyKey(options = {}) { 15 const headers = new Headers(options.headers || {}); 16 const inheritedKey = headers.get('Idempotency-Key') 17 ?.split(',', 1)[0] 18 ?.trim(); 19 const operationKey = String( 20 options.operationKey || inheritedKey || crypto.randomUUID(), 21 ).trim().toLowerCase(); 22 headers.delete('Idempotency-Key'); 23 headers.set('Idempotency-Key', operationKey); 24 return { 25 ...options, 26 operationKey, 27 headers: Object.fromEntries(headers.entries()), 28 }; 29} 30 31export const markupsApi = { 32 33 // ââ Directory & Listing ââââââââââââââââââââââââââââââââââââââ 34 35 /** List directory contents (folders + markups). directoryId=0 for root. */ 36 listDirectory: (projectId, directoryId = 0, options = {}) => 37 api.get(`markups/list/${projectId}/${directoryId}`, options), 38 39 /** Get full markup detail (pages, layers, annotations, XML, dimensions) */ 40 getMarkup: (markupId, options = {}) => 41 api.get(`markups/${markupId}`, options), 42 43 /** Get layers grouped by type for a page */ 44 getLayersByPage: (markupId, pageId, options = {}) => 45 api.get(`markups/layer/${markupId}/${pageId}`, options), 46 47 // ââ Create & Update ââââââââââââââââââââââââââââââââââââââââââ 48 49 /** Create/update a directory. data: { directoryID, projectID, parentDirectoryID, title } */ 50 saveDirectory: (data, options = {}) => 51 api.post('markups/directory/save', data, withIdempotencyKey(options)), 52 53 /** Create/update an individual layer. data: { layerID, markupID, pageNumber, type, title, isActive } */ 54 saveLayer: (data, options = {}) => 55 api.post('markups/layer/save', data, withIdempotencyKey(options)), 56 57 /** Update markup metadata (title, folder). data: { markupID, title, parentDirectoryId } */ 58 updateMarkup: (data, options = {}) => 59 api.post('markups/update', data, options), 60 61 // ââ File Operations ââââââââââââââââââââââââââââââââââââââââââ 62 63 /** 64 * Upload new markup (FormData: file + JSON metadata string as 'markup' key). 65 * Uses XHR under the hood so upload progress and cancellation work. 66 * @param {FormData} formData 67 * @param {object} [options] 68 * @param {(p: { loaded: number, total: number, pct: number }) => void} [options.onProgress] 69 * @param {AbortSignal} [options.signal] 70 */ 71 uploadMarkup: (formData, options = {}) => 72 postFormDataWithProgress(api.buildUrl('markups/save'), formData, options), 73 74 /** Save annotation XML plus optional exact GUID-to-layer ownership. */ 75 saveLayers: ({ xfdf, markupId, annotationBindings = [] }, options = {}) => { 76 if (!xfdf || !Number.isInteger(Number(markupId)) || Number(markupId) <= 0) { 77 throw new Error('Annotation save requires XFDF and a positive markupId.'); 78 } 79 80 const canonical = new Map(); 81 for (const binding of annotationBindings) { 82 const guid = binding?.guid; 83 const layerID = Number(binding?.layerID); 84 if (typeof guid !== 'string' || !guid.trim() || guid.length > 255 || !Number.isInteger(layerID) || layerID <= 0) { 85 throw new Error('Annotation bindings require a resolved positive layerID.'); 86 } 87 if (canonical.has(guid) && canonical.get(guid) !== layerID) { 88 throw new Error('One annotation cannot be bound to multiple layers.'); 89 } 90 canonical.set(guid, layerID); 91 } 92 93 const formData = new FormData(); 94 formData.append('annotationFile', new Blob([xfdf], { type: 'application/xml' }), 'annotations.xml'); 95 formData.append('MarkupId', String(markupId)); 96 [...canonical.entries()].sort(([left], [right]) =>
96 left.localeCompare(right)).forEach(([guid, layerID], index) => { 97 formData.append(`AnnotationBindings[${index}].GUID`, guid); 98 formData.append(`AnnotationBindings[${index}].LayerID`, String(layerID)); 99 }); 100 return api.postFormData('markups/save/layers', formData, withIdempotencyKey(options)); 101 }, 102 103 /** Soft-delete markup or directory. itemType: 'markup' | 'directory' */ 104 deleteItem: (itemId, itemType, options = {}) => 105 api.post(`project/markups/v2/delete/${itemId}/${itemType}`, undefined, options), 106 107 // ââ Status & Activity âââââââââââââââââââââââââââââââââââââââââ 108 109 /** Update sheet status. status: 'Draft' | 'ForReview' | 'Reviewed' | 'ForConstruction' | 'AsBuilt' */ 110 updatePageStatus: (pageId, status, options = {}) => 111 api.put(`markups/page/${pageId}/status`, { status }, withIdempotencyKey(options)), 112 113 /** Get activity feed for a markup (paginated). */ 114 getMarkupActivity: (markupId, page = 1, pageSize = 50, options = {}) => 115 api.get(`markups/${markupId}/activity?page=${page}&pageSize=${pageSize}`, options), 116 117 /** 118 * Get project items linkable from a markup annotation. Reuses the messages 119 * `linkable-items` endpoint â messageId=0 means "nothing to exclude". 120 * linkType: 'RFI' | 'CHG' | 'SI' | 'SUBMITTAL'. Returns { id, itemNumber, title, status }[]. 121 */ 122 getLinkableItems: (projectId, linkType, search = '', options = {}) => { 123 const query = new URLSearchParams({ projectId, messageId: 0, linkType }); 124 if (search) query.append('search', search); 125 return api.get(`smrtbuild/messages/linkable-items?${query}`, options); 126 }, 127 128 /** 129 * Send the extracted layer PDF as an email attachment via the MessageFacade 130 * pipeline. `formData` must carry `file` (the PDF) + `payload` (JSON of 131 * SendProjectEmailRequest â userIds/contactIds/cc/subject/message/...). 132 */ 133 sendMarkupEmail: (markupId, formData, options = {}) => 134 api.postFormData(`markups/${markupId}/send`, formData, options), 135 136 /** Supercede a page â upload new revision (FormData with file). */ 137 supercedePage: (pageId, formData, options = {}) => 138 api.postFormData(`markups/page/${pageId}/supercede`, formData, options), 139 140 /** Rename a page title. Legacy uses form-encoded `newTitle`. */ 141 renamePage: (pageId, newTitle, options = {}) => { 142 const fd = new FormData(); 143 fd.append('newTitle', newTitle); 144 return api.postFormData(`markups/page/${pageId}/rename`, fd, options); 145 }, 146 147 /** Undo a supercede â removes the replacement page, unmarks the original. */ 148 undoSupercedePage: (pageId, options = {}) => 149 api.post(`markups/page/${pageId}/undo-supercede`, {}, options), 150 151 // ââ Batch & Register ââââââââââââââââââââââââââââââââââââââââââââ 152 153 /** Batch update status for multiple sheets. */ 154 batchUpdateStatus: (pageIds, status, options = {}) => 155 api.post('markups/batch/status', { pageIds, status }, options), 156 157 /** Soft-delete exactly the selected active pages; owning markups and files remain. */ 158 batchDeletePages: (projectId, pageIds, options = {}) => 159 api.post(`markups/${projectId}/pages/batch-delete`, { pageIds }, withIdempotencyKey(options)), 160 161 /** Flat drawing register â all sheets across all markup sets. */ 162 getDrawingRegister: (projectId, options = {}) => 163 api.get(`markups/${projectId}/register`, options), 164 165 /** Generate drawing transmittal. */ 166 generateTransmittal: (data, options = {}) => 167 api.post('markups/transmittal/generate', data, withIdempotencyKey(options)), 168 169 // ââ PDF Blob (for offline storage in IndexedDB) ââââââââââââââ 170 171 /** Fetch a markup PDF as a Blob for offline storage. */ 172 fetchPdfBlob: (markupId, companyCode, options = {}) => 173 api.getBlob(`markups/file/${markupId}/${companyCode}`, options), 174 175 /** 176 * Fetch an already-resolved original-PDF URL for the Apryse repair path. 177 * This intentionally preserves the repair transport's direct URL, auth 178 * headers, response status, and ArrayBuffer result instead of routing the 179 * URL back through ApiClient endpoint construction or generic blob rules. 180 * @param {string} resolvedUrl 181 * @param {object} [options] 182 * @param {AbortSignal} [options.signal] 183 * @returns {Promise<ArrayBuffer>} 184 */ 185 fetchResolvedPdfArrayBuffer: async (resolvedUrl, options = {}) => { 186 const response = await fetch(resolvedUrl, { 187 headers: markupsApi.getAuthHeaders(), 188 signal: options.signal, 189 }); 190 if (response.status === 401) { 191 await auth.logout('/login', { preemptCurrentTransition: true }); 192 const error = new Error('Unauthorized - please login again');
193 error.name = 'AuthExpiredError'; 194 error.status = 401; 195 error.lifecycleCancelled = true; 196 throw error; 197 } 198 if (!response.ok) throw new Error(`Fetch for repair returned ${response.status}`); 199 return await response.arrayBuffer(); 200 }, 201 202 // ââ URL Builders (not API calls â return URL strings) ââââââââ 203 204 /** Build authenticated PDF URL for WebViewer (online loading). */ 205 buildPdfUrl: (markupId, companyCode) => 206 api.buildUrl(`markups/file/${markupId}/${companyCode}?t=${Date.now()}`), 207 208 /** Get auth headers for WebViewer custom headers. */ 209 getAuthHeaders: () => { 210 const headers = {}; 211 const token = auth.getToken(); 212 if (token) headers['Authorization'] = `Bearer ${token}`; 213 if (config.apiKey) headers['X-API-Key'] = config.apiKey; 214 return headers; 215 }, 216 217 // ââ Log Exports ââ 218 downloadRegisterPdf: (projectId) => api.getBlob(`markups/${projectId}/register/log/pdf`), 219 downloadRegisterExcel: (projectId) => api.getBlob(`markups/${projectId}/register/log/excel`), 220 downloadTransmittalLogPdf: (projectId) => api.getBlob(`markups/${projectId}/transmittal-log/pdf`), 221 downloadTransmittalLogExcel: (projectId) => api.getBlob(`markups/${projectId}/transmittal-log/excel`), 222};
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.