///////////////////////////////////////////////////////////////////////////////
// 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
* 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;
}
}