PageSourceSearch

https://smrtbld.ai/js/api/markups.js

js smrtbld.ai collected 2026-10-05 15:08:44 UTC 10,756 bytes, 222 lines download raw bytes

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.