From efc8144acc1cb59e45d7be57063c275572385df3 Mon Sep 17 00:00:00 2001 From: David Sehnal <david.sehnal@gmail.com> Date: Wed, 21 Nov 2018 12:00:59 +0100 Subject: [PATCH] ajax download with progress tracking --- src/mol-plugin/context.ts | 8 +- src/mol-plugin/state/transforms/data.ts | 3 +- src/mol-util/data-source.ts | 187 ++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 src/mol-util/data-source.ts diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 38bf3dcb9..af037145a 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -23,6 +23,7 @@ import { PluginState } from './state'; import { TaskManager } from './util/task-manager'; import { Color } from 'mol-util/color'; import { LociLabelEntry, LociLabelManager } from './util/loci-label-manager'; +import { ajaxGet } from 'mol-util/data-source'; export class PluginContext { private disposed = false; @@ -96,9 +97,10 @@ export class PluginContext { * This should be used in all transform related request so that it could be "spoofed" to allow * "static" access to resources. */ - async fetch(url: string, type: 'string' | 'binary' = 'string'): Promise<string | Uint8Array> { - const req = await fetch(url, { referrerPolicy: 'origin-when-cross-origin' }); - return type === 'string' ? await req.text() : new Uint8Array(await req.arrayBuffer()); + fetch(url: string, type: 'string' | 'binary' = 'string'): Task<string | Uint8Array> { + return ajaxGet({ url, type }); + //const req = await fetch(url, { referrerPolicy: 'origin-when-cross-origin' }); + // return type === 'string' ? await req.text() : new Uint8Array(await req.arrayBuffer()); } runTask<T>(task: Task<T>) { diff --git a/src/mol-plugin/state/transforms/data.ts b/src/mol-plugin/state/transforms/data.ts index d2f76a919..d663997c5 100644 --- a/src/mol-plugin/state/transforms/data.ts +++ b/src/mol-plugin/state/transforms/data.ts @@ -29,8 +29,7 @@ const Download = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.B }), apply({ params: p }, globalCtx: PluginContext) { return Task.create('Download', async ctx => { - // TODO: track progress - const data = await globalCtx.fetch(p.url, p.isBinary ? 'binary' : 'string'); + const data = await globalCtx.fetch(p.url, p.isBinary ? 'binary' : 'string').runInContext(ctx); return p.isBinary ? new SO.Data.Binary(data as Uint8Array, { label: p.label ? p.label : p.url }) : new SO.Data.String(data as string, { label: p.label ? p.label : p.url }); diff --git a/src/mol-util/data-source.ts b/src/mol-util/data-source.ts new file mode 100644 index 000000000..8545b9397 --- /dev/null +++ b/src/mol-util/data-source.ts @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + * + * Adapted from LiteMol + */ + +import { Task, RuntimeContext } from 'mol-task'; +import { utf8Read } from 'mol-io/common/utf8'; + +export enum DataCompressionMethod { + None, + Gzip +} + +export interface AjaxGetParams { + url: string, + type: 'string' | 'binary', + title?: string, + compression?: DataCompressionMethod +} + +export function readStringFromFile(file: File) { + return <Task<string>>readFromFileInternal(file, false); +} + +export function readUint8ArrayFromFile(file: File) { + return <Task<Uint8Array>>readFromFileInternal(file, true); +} + +export function readFromFile(file: File, type: 'string' | 'binary') { + return <Task<Uint8Array | string>>readFromFileInternal(file, type === 'binary'); +} + +export function ajaxGetString(url: string, title?: string) { + return <Task<string>>ajaxGetInternal(title, url, false, false); +} + +export function ajaxGetUint8Array(url: string, title?: string) { + return <Task<Uint8Array>>ajaxGetInternal(title, url, true, false); +} + +export function ajaxGet(params: AjaxGetParams) { + return <Task<string | Uint8Array>>ajaxGetInternal(params.title, params.url, params.type === 'binary', params.compression === DataCompressionMethod.Gzip); +} + +function decompress(buffer: Uint8Array): Uint8Array { + // TODO + throw 'nyi'; + // const gzip = new LiteMolZlib.Gunzip(new Uint8Array(buffer)); + // return gzip.decompress(); +} + +async function processFile(ctx: RuntimeContext, asUint8Array: boolean, compressed: boolean, e: any) { + const data = (e.target as FileReader).result; + + if (compressed) { + await ctx.update('Decompressing...'); + + const decompressed = decompress(new Uint8Array(data as ArrayBuffer)); + if (asUint8Array) { + return decompressed; + } else { + return utf8Read(decompressed, 0, decompressed.length); + } + } else { + return asUint8Array ? new Uint8Array(data as ArrayBuffer) : data as string; + } +} + +function readData(ctx: RuntimeContext, action: string, data: XMLHttpRequest | FileReader, asUint8Array: boolean): Promise<any> { + return new Promise<any>((resolve, reject) => { + data.onerror = (e: any) => { + const error = (<FileReader>e.target).error; + reject(error ? error : 'Failed.'); + }; + + data.onabort = () => reject(Task.Aborted('')); + + data.onprogress = (e: ProgressEvent) => { + if (e.lengthComputable) { + ctx.update({ message: action, isIndeterminate: false, current: e.loaded, max: e.total }); + } else { + ctx.update({ message: `${action} ${(e.loaded / 1024 / 1024).toFixed(2)} MB`, isIndeterminate: true }); + } + } + data.onload = (e: any) => resolve(e); + }); +} + +function readFromFileInternal(file: File, asUint8Array: boolean): Task<string | Uint8Array> { + let reader: FileReader | undefined = void 0; + return Task.create('Read File', async ctx => { + try { + reader = new FileReader(); + const isCompressed = /\.gz$/i.test(file.name); + + if (isCompressed || asUint8Array) reader.readAsArrayBuffer(file); + else reader.readAsBinaryString(file); + + ctx.update({ message: 'Opening file...', canAbort: true }); + const e = await readData(ctx, 'Reading...', reader, asUint8Array); + const result = processFile(ctx, asUint8Array, isCompressed, e); + return result; + } finally { + reader = void 0; + } + }, () => { + if (reader) reader.abort(); + }); +} + +class RequestPool { + private static pool: XMLHttpRequest[] = []; + private static poolSize = 15; + + static get() { + if (this.pool.length) { + return this.pool.pop()!; + } + return new XMLHttpRequest(); + } + + static emptyFunc() { } + + static deposit(req: XMLHttpRequest) { + if (this.pool.length < this.poolSize) { + req.onabort = RequestPool.emptyFunc; + req.onerror = RequestPool.emptyFunc; + req.onload = RequestPool.emptyFunc; + req.onprogress = RequestPool.emptyFunc; + this.pool.push(req); + } + } +} + +async function processAjax(ctx: RuntimeContext, asUint8Array: boolean, decompressGzip: boolean, e: any) { + const req = (e.target as XMLHttpRequest); + if (req.status >= 200 && req.status < 400) { + if (asUint8Array) { + const buff = new Uint8Array(e.target.response); + RequestPool.deposit(e.target); + + if (decompressGzip) { + return decompress(buff); + } else { + return buff; + } + } + else { + const text = e.target.responseText; + RequestPool.deposit(e.target); + return text; + } + } else { + const status = req.statusText; + RequestPool.deposit(e.target); + throw status; + } +} + +function ajaxGetInternal(title: string | undefined, url: string, asUint8Array: boolean, decompressGzip: boolean): Task<string | Uint8Array> { + let xhttp: XMLHttpRequest | undefined = void 0; + return Task.create(title ? title : 'Download', async ctx => { + try { + if (!asUint8Array && decompressGzip) { + throw 'Decompress is only available when downloading binary data.'; + } + + xhttp = RequestPool.get(); + + xhttp.open('get', url, true); + xhttp.responseType = asUint8Array ? 'arraybuffer' : 'text'; + xhttp.send(); + + ctx.update({ message: 'Waiting for server...', canAbort: true }); + const e = await readData(ctx, 'Downloading...', xhttp, asUint8Array); + const result = await processAjax(ctx, asUint8Array, decompressGzip, e) + return result; + } finally { + xhttp = void 0; + } + }, () => { + if (xhttp) xhttp.abort(); + }); +} \ No newline at end of file -- GitLab