/////////////////////////////////////////////////////////////////////////////// // Copyright (C) 2002-2023, Open Design Alliance (the "Alliance"). // All rights reserved. // // This software and its documentation and related materials are owned by // the Alliance. The software may only be incorporated into application // programs owned by members of the Alliance, subject to a signed // Membership Agreement and Supplemental Software License Agreement with the // Alliance. The structure and organization of this software are the valuable // trade secrets of the Alliance and its suppliers. The software is also // protected by copyright law and international treaty provisions. Application // programs incorporating this software must include the following statement // with their copyright notices: // // This application incorporates Open Design Alliance software pursuant to a // license agreement with Open Design Alliance. // Open Design Alliance Copyright (C) 2002-2021 by Open Design Alliance. // All rights reserved. // // By use of this software, its documentation or related materials, you // acknowledge and accept the above terms. /////////////////////////////////////////////////////////////////////////////// import { IHttpClient } from "./IHttpClient"; import { IFileStatus, IFileReferences, IFileVersionInfo } from "./IFile"; import { IShortUserDescription } from "./IUser"; import { Model } from "./Model"; import { Permission } from "./Permission"; import { Job } from "./Job"; import { json, text, downloadProgress, downloadPartOfFile, waitFor, parseArgs, userFullName, userInitials, } from "./impl/Utils"; /** * The class representing a `file` entity. */ export class File { private _data: any; private _useVersion: number | undefined; public httpClient: IHttpClient; public path: string; /** * @param data - An object that implements file data storage. * @param httpClient - Http client for API. */ constructor(data: any, httpClient: IHttpClient) { this.path = `/files/${data.id}`; this.httpClient = httpClient; this.data = data; } private appendVersionParam(relativePath: string): string { if (this._useVersion === undefined) return relativePath; const delimiter = relativePath.includes("?") ? "&" : "?"; return `${relativePath}${delimiter}version=${this._useVersion}`; } private internalGet(relativePath: string, signal?: AbortSignal) { relativePath = this.appendVersionParam(relativePath); return this.httpClient.get(`${this.path}${relativePath}`, signal); } private internalPost( relativePath: string, body?: ArrayBuffer | Blob | globalThis.File | FormData | object | string | null ) { relativePath = this.appendVersionParam(relativePath); return this.httpClient.post(`${this.path}${relativePath}`, body); } private internalPut( relativePath: string, body?: ArrayBuffer | Blob | globalThis.File | FormData | object | string | null ) { relativePath = this.appendVersionParam(relativePath); return this.httpClient.put(`${this.path}${relativePath}`, body); } private internalDelete(relativePath: string) { relativePath = this.appendVersionParam(relativePath); return this.httpClient.delete(`${this.path}${relativePath}`); } async partialDownloadResource( dataId: string, onProgress?: (progress: number, downloaded: Uint8Array) => void, signal?: AbortSignal ) { let pathname = `${this.path}/downloads/${dataId}`; pathname = this.appendVersionParam(pathname); // TODO: replace with $get to handle fetch errors const response = await fetch(`${this.httpClient.serverUrl}${pathname}`, { headers: this.httpClient.headers, signal, }); // TODO: use ReadableStream pipeTo() const contentLength = response.headers.get("Content-Length") ?? ""; const total = parseInt(contentLength, 10); const reader = response.body.getReader(); let loaded = 0; while (true) { const { done, value } = await reader.read(); if (done) break; loaded += value.byteLength; if (typeof onProgress === "function") onProgress(loaded / total, value); } } downloadFileRange( requestId: number, records: any | null, dataId: string, onProgress?: (progress: number, downloaded: Uint8Array, requestId: number) => void, signal?: AbortSignal ) { let pathname = `${this.path}/downloads/${dataId}?requestId=${requestId}`; pathname = this.appendVersionParam(pathname); return downloadPartOfFile( requestId, records, `${this.httpClient.serverUrl}${pathname}`, this.httpClient.headers, onProgress, signal ); } /** * Active version number of the file. * * @readonly */ get activeVersion(): number { return this.data.activeVersion; } /** * File creation time (UTC) in the format specified in ISO 8601. * * @readonly */ get created(): string { return this.data.created; } /** * File custom fields object, to store custom data. * * @readonly */ get customFields(): any { return this.data.customFields; } set customFields(value: any) { this.data.customFields = value; } /** * Raw file data received from the server. * * @readonly */ get data(): any { return this._data; } private set data(value: any) { this._data = value; this._data.previewUrl = value.preview ? `${this.httpClient.serverUrl}${this.path}/preview` : ""; // owner since 24.8 if (typeof this._data.owner === "string") this._data.owner = { userId: this._data.owner }; this._data.owner ??= {}; this._data.owner.avatarUrl = `${this.httpClient.serverUrl}/users/${this._data.owner.userId}/avatar`; this._data.owner.fullName = userFullName(this._data.owner); this._data.owner.initials = userInitials(this._data.owner.fullName); // status since 24.9 this._data.status ??= {}; this._data.status.geometry ??= { state: this._data.geometryStatus ?? "none" }; this._data.status.properties ??= { state: this._data.propertiesStatus ?? "none" }; this._data.status.validation ??= { state: this._data.validationStatus ?? "none" }; // updatedBy since 24.10 this._data.updatedBy ??= {}; this._data.updatedBy.avatarUrl = `${this.httpClient.serverUrl}/users/${this._data.updatedBy.userId}/avatar`; this._data.updatedBy.fullName = userFullName(this._data.updatedBy); this._data.updatedBy.initials = userInitials(this._data.updatedBy.fullName); // geometryGltf status since 24.12 this._data.status.geometryGltf ??= { state: "none" }; } /** * Returns a list of formats in which the active version of the file was exported. To export * file to one of the supported formats create File Converter job using * {@link File.createJob()}. To download exported file use {@link File.downloadResource()}. * * @readonly */ get exports(): string[] { return this.data.exports; } /** * Geometry data type of the active file version. Can be one of: * * - `vsfx` - `VSFX`, file can be opened in `VisualizeJS` viewer. * - `gltf` - `glTF`, file can be opened in `Three.js` viewer. * * Returns an empty string if geometry data has not yet been extracted. A files without * geometry data can be exported to other formas, but cannot be opened in viewer. */ get geometryType(): string { if (this.status.geometryGltf.state === "done") return "gltf"; else if (this.status.geometry.state === "done") return "vsfx"; else return ""; } /** * Unique file ID. * * @readonly */ get id(): string { return this.data.id; } /** * File name, including the extension. */ get name(): string { return this.data.name; } set name(value: string) { this.data.name = value; } /** * If the file is a version, then returns the ID of the original file. Otherwise, returns the file ID. * * @readonly */ get originalFileId(): string { return this.data.originalFileId; } /** * File owner information. * * @property {string} userId - User ID. * @property {string} userName - User name. * @property {string} name - First name. * @property {string} lastName - Last name. * @property {string} fullName - Full name. * @property {string} initials - Initials. * @property {string} email - User email. * @property {string} avatarUrl - User avatar image URL. * @readonly */ get owner(): IShortUserDescription { return this.data.owner; } /** * File preview image URL. Use {@link File#setPreview | setPreview()} to change preview image. * * @readonly */ get previewUrl(): string { return this.data.previewUrl; } /** * The size of the active version of the file in bytes. * * @readonly */ get size(): number { return this.data.size; } /** * Total size of all versions of the file in the storage in bytes. * * @readonly */ get sizeTotal(): number { return this.data.sizeTotal; } /** * Data status of the active version of the file. Contains: * * - `geometry` - status of geometry data of `VSFX` type. * - `geometryGltf` - status of geometry data of `glTF` type. * - `properties` - status of properties. * - `validation` - status of validation. * * Each status entity is a record with properties: * * - `state` - Data state. Can be `none`, `waiting`, `inprogress`, `done` or `failed`. * - `jobId` - Unique ID of the data job. * * @readonly */ get status(): IFileStatus { return this.data.status; } /** * File type, matches the file extension. * * @readonly */ get type(): string { return this.data.type; } /** * File last update time (UTC) in the format specified in ISO 8601. * * @readonly */ get updatedAt(): string { return this.data.updatedAt; } /** * Information about the user who made the last update. * * @property {string} userId - User ID. * @property {string} userName - User name. * @property {string} name - First name. * @property {string} lastName - Last name. * @property {string} fullName - Full name. * @property {string} initials - Initials. * @property {string} email - User email. * @property {string} avatarUrl - User avatar image URL. * @readonly */ get updatedBy(): IShortUserDescription { return this.data.updatedBy; } /** * Zero-based file version number for version files. The original file has version `0`. */ get version(): number { return this.data.version; } /** * List of the file versions. * * @readonly */ get versions(): IFileVersionInfo[] { return this.data.versions; } /** * Refresh file data. * * @async */ async checkout(): Promise { this.data = await json(this.internalGet("")); return this; } /** * Update file data on the server. * * @async * @param data - Raw file data. */ async update(data: any): Promise { this.data = await json(this.internalPut("", data)); return this; } /** * Delete the file and all its versions from the server. * * @async * @returns Returns the raw data of a deleted file. */ delete(): Promise { return json(this.internalDelete("")); } /** * Save file data changes to the server. Call this method to update file data on the server * after any changes. * * @async */ save(): Promise { return this.update(this.data); } /** * Set or remove the file preview. * * @async * @param image - Preview image. Can be a Data URL string, ArrayBuffer, Blob or * Web API File object. Setting the `image` to `null` will remove the preview. */ async setPreview(image?: ArrayBuffer | Blob | globalThis.File | FormData | string | null): Promise { if (image) { this.data = await json(this.internalPost("/preview", image)); } else { this.data = await json(this.internalDelete("/preview")); } return this; } /** * Returns a list of models of the active version of the file. * * @async */ getModels(): Promise { return json(this.internalGet("/geometry")).then((array) => array.map((data) => new Model(data, this))); } // File does not support model transformation. getModelTransformMatrix(handle: string): any { return undefined; } setModelTransformMatrix(handle: string, transform: any): Promise { console.warn("File does not support model transformation"); return Promise.resolve(this); } /** * Object properties. * * @typedef {any} Properties * @property {string} handle - Object original handle. * @property {string | any} * - Object property. Can be `any` for nested properties. */ /** * Returns the properties for an objects in the active version of the file. * * @async * @param handles - Object original handle or handles array. Leave this parameter `undefined` * to get properties for all objects in the file. */ getProperties(handles?: string | string[]): Promise { return json(this.internalGet(handles !== undefined ? `/properties?handles=${handles}` : "/properties")); } /** * Search pattern. * * @typedef {any} SearchPattern * @property {string} key - Property name. * @property {string} value - Property value. */ /** * Query operator. Operator name can be `$and`, `$or`, `$not`, `$eq`, `$regex`. * * @typedef {any} QueryOperator * @property {string | SearchPattern[] | QueryOperator[]} * - Array of the query values or * patterns for operator. */ /** * Returns the list of original handles for an objects in the active version of the file that * match the specified patterns. Search patterns may be combined using query operators. * * @async * @example Simple search pattern. * searchPattern = { * key: "Category", * value: "OST_Stairs", * }; * * @example Search patterns combination. * searchPattern = { * $or: [ * { * $and: [ * { key: "Category", value: "OST_GenericModel" }, * { key: "Level", value: "03 - Floor" }, * ], * }, * { key: "Category", value: "OST_Stairs" }, * ], * }; * * @param {SearchPattern | QueryOperator} searchPattern - Search pattern or combination of * the patterns, see example below. * @returns {Promise} */ searchProperties(searchPattern: any): Promise { return json(this.internalPost("/properties/search", searchPattern)); } /** * Returns the cda.json for an active version of the file. * * @async */ getCdaTree(): Promise { return json(this.internalGet(`/properties/tree`)); } /** * Returns a list of file viewpoints. * * @async */ getViewpoints(): Promise { return json(this.internalGet("/viewpoints")).then((viewpoints) => viewpoints.result); } /** * Add new file viewpoint. To create a new viewpoint use * {@link Viewer#createViewpoint | Viewer.createViewpoint()}. * * @async * @param viewpoint - Viewpoint. */ saveViewpoint(viewpoint: any): Promise { return json(this.internalPost("/viewpoints", viewpoint)); } /** * Delete file viewpoint. * * @async * @param guid - Viewpoint GUID. * @returns Returns the raw data of a deleted viewpoint. */ deleteViewpoint(guid: string): Promise { return json(this.internalDelete(`/viewpoints/${guid}`)); } /** * Returns viewpoint preview image as Data URL. * * @async * @param guid - Viewpoint GUID. */ getSnapshot(guid: string): Promise { return text(this.internalGet(`/viewpoints/${guid}/snapshot`)); } /** * Returns viewpoint preview data. * * @async * @param guid - Viewpoint GUID. * @param bitmapGuid - Bitmap GUID. */ getSnapshotData(guid: string, bitmapGuid: string): Promise { return text(this.internalGet(`/viewpoints/${guid}/bitmaps/${bitmapGuid}`)); } /** * Download source of active version of the file. * * @async * @param onProgress - Download progress callback. * @param signal - An AbortSignal object instance. Allows to communicate with a fetch request * and abort it if desired. */ download(onProgress?: (progress: number) => void, signal?: AbortSignal): Promise { return this.internalGet(`/downloads`, signal) .then((response) => downloadProgress(response, onProgress)) .then((response) => response.arrayBuffer()); } /** * Download file resource data of the active version of the file, such as exported file. * * @example Export file to DWG. * const job = await file.crateJob("dwg"); * await job.waitForDone(); * const resourceId = file.exports.find((x) => x.endsWith(".dwg")); * const arrayBuffer = await file.downloadResource(resourceId); * const blob = new Blob([arrayBuffer]); * const fileName = file.name.replace(/\.[^.]+$/, "") + ".dwg"; * FileSaver.saveAs(blob, fileName); * * @param dataId - Resource ID. * @param onProgress - Download progress callback. * @param signal - An AbortSignal object instance. Allows to communicate with a fetch request * and abort it if desired. */ downloadResource( dataId: string, onProgress?: (progress: number) => void, signal?: AbortSignal ): Promise { return this.internalGet(`/downloads/${dataId}`, signal) .then((response) => downloadProgress(response, onProgress)) .then((response) => response.arrayBuffer()); } /** * Returns a list of references to files used to correct rendering of the current file. * * @async * @param [signal] - An AbortController * signal object instance, which can be used to abort waiting as desired. */ getReferences(signal?: AbortSignal): Promise { return json(this.internalGet("/references", signal)); } /** * Set the file references. * * @async * @param references - File references. */ setReferences(references: IFileReferences): Promise { return json(this.internalPut("/references", references)); } /** * Create a new job for the active version of the file. * * @async * @param outputFormat - The job type. Can be one of: * * - `geometry` - Extract file geometry data into `VSFX`. * - `geometryGltf` - Extract file geometry data into `glTF`. * - `properties` - Extract file properties. * - `validation` - Validate the file. Only for `IFC`. * - `dwg`, `obj`, `gltf`, `glb`, `vsf`, `pdf`, `3dpdf` - Export file to the one of the * supported format. Use {@link File.exports | File.exports()} to get the list of * completed file exports. Use {@link File.downloadResource | File.downloadResource()} * to download the exported file. * - Other custom job name. Custom job runner must be registered in the job templates table * before creating a job. * * @param parameters - Parameters for the job runner. Can be given as command line arguments * for the File Converter tool in form "--arg=value". */ createJob(outputFormat: string, parameters?: string | object): Promise { const pathname = this.appendVersionParam("/jobs"); return this.httpClient .post(pathname, { fileId: this.id, outputFormat, parameters: parseArgs(parameters), }) .then((response) => response.json()) .then((data) => new Job(data, this.httpClient)); } /** * Create job to extract geometry data of active version of the file. This is alias to * {@link File.createJob | File.createJob("geometry")}. * * @async * @param type - Geometry data type. Can be one of: * * - `vsfx` - `VSFX` (default), for opening a file in `VisualizeJS` viewer. * - `gltf` - `glTF`, for opening a file in `Three.js` viewer. * * @param parameters - Parameters for the job runner. Can be given as command line arguments * for the File Converter tool in form "--arg=value". */ extractGeometry(type?: string, parameters?: string | object): Promise { return this.createJob(type === "gltf" ? "geometryGltf" : "geometry", parameters); } /** * Create job to extract properties of the active version of the file. This is alias to * {@link File.createJob | File.createJob("properties")}. * * @async * @param parameters - Parameters for the job runner. Can be given as command line arguments * for the File Converter tool in form "--arg=value". */ extractProperties(parameters?: string | object): Promise { return this.createJob("properties", parameters); } /** * Create a job to validate the active version of the file. This is alias to * {@link File.createJob | File.createJob("validation")}. * * To get validation report use * {@link File.downloadResource | File.downloadResource("validation_report.json")}. * * @async * @param parameters - Parameters for the job runner. Can be given as command line arguments * for the File Converter tool in form "--arg=value". */ validate(parameters?: string | object): Promise { return this.createJob("validation", parameters); } /** * Wait for jobs of the active version of the file to be done. Job is done when it changes to * `none`, `done` or `failed` status. * * @async * @param {string | string[]} jobs - Job or job array to wait on. Can be `geometry`, * `geometryGltf`, `properties`, `validation`, `dwg`, `obj`, `gltf`, `glb`, `vsf`, `pdf`, * `3dpdf` or custom job name. * @param {Boolean} [waitAll] - If this parameter is `true`, the function returns when all * the specified jobs have done. If `false`, the function returns when any one of the jobs are done. * @param {Object} [params] - An object containing waiting parameters. * @param {Number} [params.timeout] - The time, in milliseconds that the function should wait * jobs. If no one jobs are done during this time, the `TimeoutError` exception will be thrown. * @param {Number} [params.interval] - The time, in milliseconds, the function should delay * in between checking jobs status. * @param {AbortSignal} [params.signal] - An AbortController * signal object instance, which can be used to abort waiting as desired. * @param {function} [params.onCheckout] - Waiting progress callback. Return `true` to cancel waiting. * @returns {Promise} */ waitForDone( jobs, waitAll?: boolean, params?: { timeout?: number; interval?: number; signal?: AbortSignal; onCheckout?: (file: File, ready: boolean) => boolean; } ): Promise { if (!Array.isArray(jobs)) jobs = [jobs]; if (waitAll === undefined) waitAll = true; const checkDone = () => this.checkout().then((file) => { const readyJobs = jobs.filter((x: string) => ["none", "done", "failed"].includes(file.status[x]?.state ?? "none") ); const ready = waitAll ? readyJobs.length === jobs.length : readyJobs.length > 0; const cancel = params?.onCheckout?.(file, ready); return cancel || ready; }); return waitFor(checkDone, params).then(() => this); } /** * Returns a list of file permissions. * * @async */ getPermissions(): Promise { return json(this.internalGet("/permissions")).then((array) => array.map((data) => new Permission(data, this.id, this.httpClient)) ); } /** * Returns the permission information. * * @async * @param permissionId - Permission ID. */ getPermission(permissionId: string): Promise { return json(this.internalGet(`/permissions/${permissionId}`)).then( (data) => new Permission(data, this.id, this.httpClient) ); } /** * Create a new file permission. * * @async * @param actions - Actions are allowed to be performed on a file with this permission. See * {@link Permission#actions | Permission.actions} for more details. * @param grantedTo - A collection of principials that will get access to the file. * @param public = false - Specifies whether all users have access to the file or not. */ createPermission(actions: string | string[], grantedTo: any[], _public: boolean): Promise { return json( this.internalPost("/permissions", { actions: typeof actions === "string" ? [actions] : actions, grantedTo, public: _public, }) ).then((data) => new Permission(data, this.id, this.httpClient)); } /** * Delete file permission. * * @async * @param permissionId - Permission ID. * @returns Returns the raw data of a deleted permission. */ deletePermission(permissionId: string): Promise { return json(this.internalDelete(`/permissions/${permissionId}`)); } /** * Upload the new version of the file to the server and extract the geometry and properties as needed. * * @async * @param file - Web API File object are generally retrieved from a FileList * object returned as a result of a user selecting files using the HTML `` element. * @param params - An object containing upload parameters. * @param params.geometry=true - Create job to extract file geometry data. The geometry data * type is the same as the original file. * @param params.properties=false - Create job to extract file properties. * @param params.waitForDone=false - Wait for geometry and properties jobs to complete. * @param params.timeout - The time, in milliseconds that the function should wait jobs. If * no one jobs are done during this time, the `TimeoutError` exception will be thrown. * @param params.interval - The time, in milliseconds, the function should delay in between * checking jobs status. * @param params.signal - An AbortController * signal object instance, which can be used to abort waiting as desired. * @param params.onProgress - Upload progress callback. */ async uploadVersion( file: globalThis.File, params: { geometry?: boolean; properties?: boolean; waitForDone?: boolean; timeout?: number; interval?: number; signal?: AbortSignal; onProgress?: (progress: number, file: globalThis.File) => void; } = { waitForDone: false, } ): Promise { const result = await this.httpClient .postFile(`${this.path}/versions`, file, (progress) => params.onProgress?.(progress, file)) .then((xhr: XMLHttpRequest) => JSON.parse(xhr.responseText)) .then((data) => new File(data, this.httpClient)); if (params.geometry === undefined && this.geometryType !== "") params.geometry = true; if (params.properties === undefined && this.status.properties.state !== "none") params.properties = true; const jobs: string[] = []; if (params.geometry) jobs.push((await result.extractGeometry(this.geometryType)).outputFormat); if (params.properties) jobs.push((await result.extractProperties()).outputFormat); if (params.waitForDone && jobs.length > 0) await result.waitForDone(jobs, true, params); await this.checkout(); return result; } /** * Returns a list of version files. * * @async */ getVersions(): Promise { return this.internalGet("/versions") .then((response) => response.json()) .then((files) => files.map((data) => new File(data, this.httpClient))) .then((files) => files.map((file) => (file.id == file.originalFileId ? file.useVersion(0) : file))); } /** * Returns the version file. * * @async * @param version - Desired version. */ getVersion(version: number): Promise { return this.internalGet(`/versions/${version}`) .then((response) => response.json()) .then((data) => new File(data, this.httpClient)) .then((file) => (file.id == file.originalFileId ? file.useVersion(0) : file)); } /** * Delete version file. * * @async * @param version - Version to delete. */ async deleteVersion(version: number): Promise { const response = await this.internalDelete(`/versions/${version}`); const result = await response.json(); await this.checkout(); return result; } /** * Replace the active version of the file with the selected version. * * @async * @param version - Desired active version. */ setActiveVersion(version: number): Promise { return this.update({ activeVersion: version }); } /** * Use given version instead of active version for current file on client side. This version * change will affect the result: * * - getModels() * - getProperties() * - searchProperties() * - getCdaTree() * - download() * - downloadResource() * - createJob() * - extractGeometry() * - extractProperties() * - validate() * - waitForDone() * - Viewer.open() * * Other clients will still continue to use the currently active version of the file. Use * `undefined` to revert back to the active version. * * Note. You need to update the file data using [File.checkout()]{@link File#checkout} to * match the size and status fields to the version you selected. */ useVersion(version?: number): this { this._useVersion = version; return this; } }