src_io_urlsLoader.js

import {endsWith, getRootPath} from '../utils/string';
import {MultiProgressHandler} from '../utils/progress';
import {getFileListFromDicomDir} from '../dicom/dicomElementsWrapper';
import {loaderList} from './loaderList';

// url content types
export const urlContentTypes = {
  Text: 0,
  ArrayBuffer: 1
};

/**
 * Urls loader.
 */
export class UrlsLoader {

  /**
   * Input data.
   *
   * @type {string[]}
   */
  #inputData = null;

  /**
   * Array of launched requests.
   *
   * @type {XMLHttpRequest[]}
   */
  #requests = [];

  /**
   * Data loader.
   *
   * @type {object}
   */
  #runningLoader = null;

  /**
   * Number of loaded data.
   *
   * @type {number}
   */
  #nLoad = 0;

  /**
   * Number of load end events.
   *
   * @type {number}
   */
  #nLoadend = 0;

  /**
   * Flag to know if the load is aborting.
   *
   * @type {boolean}
   */
  #aborting;

  /**
   * The default character set (optional).
   *
   * @type {string}
   */
  #defaultCharacterSet;

  /**
   * Get the default character set.
   *
   * @returns {string} The default character set.
   */
  getDefaultCharacterSet() {
    return this.#defaultCharacterSet;
  }

  /**
   * Set the default character set.
   *
   * @param {string} characterSet The character set.
   */
  setDefaultCharacterSet(characterSet) {
    this.#defaultCharacterSet = characterSet;
  }

  /**
   * Store the current input.
   *
   * @param {string[]} data The input data.
   */
  #storeInputData(data) {
    this.#inputData = data;
    // reset counters
    this.#nLoad = 0;
    this.#nLoadend = 0;
    // reset flag
    this.#aborting = false;
    // clear storage
    this.#clearStoredRequests();
    this.#clearStoredLoader();
  }

  /**
   * Store a launched request.
   *
   * @param {XMLHttpRequest} request The launched request.
   */
  #storeRequest(request) {
    this.#requests.push(request);
  }

  /**
   * Clear the stored requests.
   *
   */
  #clearStoredRequests() {
    this.#requests = [];
  }

  /**
   * Store the launched loader.
   *
   * @param {object} loader The launched loader.
   */
  #storeLoader(loader) {
    this.#runningLoader = loader;
  }

  /**
   * Clear the stored loader.
   *
   */
  #clearStoredLoader() {
    this.#runningLoader = null;
  }

  /**
   * Increment the number of loaded data
   *   and call onload if loaded all data.
   *
   * @param {object} _event The load data event.
   */
  #addLoad = (_event) => {
    this.#nLoad++;
    // call onload when all is loaded
    // (not using the input event since it is
    //   an individual load)
    if (this.#nLoad === this.#inputData.length) {
      this.onload({
        source: this.#inputData
      });
    }
  };

  /**
   * Increment the counter of load end events
   *   and run callbacks when all done, erroneus or not.
   *
   * @param {object} _event The load end event.
   */
  #addLoadend = (_event) => {
    this.#nLoadend++;
    // call onloadend when all is run
    // (not using the input event since it is
    //   an individual load end)
    if (this.#nLoadend === this.#inputData.length) {
      this.onloadend({
        source: this.#inputData
      });
    }
  };

  /**
   * @callback eventFn
   * @param {object} event The event.
   */

  /**
   * Augment a callback event with a srouce.
   *
   * @param {object} callback The callback to augment its event.
   * @param {object} source The source to add to the event.
   * @returns {eventFn} The augmented callback.
   */
  #augmentCallbackEvent(callback, source) {
    return (event) => {
      event.source = source;
      callback(event);
    };
  }

  /**
   * Load a list of URLs or a DICOMDIR.
   *
   * @param {string[]} data The list of urls to load.
   * @param {object} [options] Load options.
   */
  load(data, options) {
    // send start event
    this.onloadstart({
      source: data
    });

    // check if DICOMDIR case
    if (data.length === 1 &&
      (endsWith(data[0], 'DICOMDIR') ||
      endsWith(data[0], '.dcmdir'))) {
      this.#loadDicomDir(data[0], options);
    } else {
      this.#loadUrls(data, options);
    }
  }

  /**
   * Get a load handler for a data element.
   *
   * @param {object} loader The associated loader.
   * @param {string} dataElement The data element.
   * @param {number} i The index of the element.
   * @returns {eventFn} A load handler.
   */
  #getLoadHandler(loader, dataElement, i) {
    return (event) => {
      // check response status
      // https://developer.mozilla.org/en-US/docs/Web/HTTP/Response_codes
      // status 200: "OK"; status 0: "debug"
      const status = event.target.status;
      if (status !== 200 && status !== 0) {
        this.onerror({
          source: dataElement,
          error: 'GET ' + event.target.responseURL +
            ' ' + event.target.status +
            ' (' + event.target.statusText + ')',
          target: event.target
        });
        this.#addLoadend();
      } else {
        loader.load(event.target.response, dataElement, i);
      }
    };
  }

  /**
   * Load a list of urls.
   *
   * @param {string[]} data The list of urls to load.
   * @param {object} [options] The options object, can contain:
   * - requestHeaders: an array of {name, value} to use as request headers,
   * - withCredentials: boolean xhr.withCredentials flag to pass
   *   to the request,
   * - batchSize: the size of the request url batch.
   */
  #loadUrls(data, options) {
    // check input
    if (typeof data === 'undefined' || data.length === 0) {
      return;
    }
    this.#storeInputData(data);

    // create prgress handler
    const mproghandler = new MultiProgressHandler(this.onprogress);
    mproghandler.setNToLoad(data.length);

    // create loaders
    const loaders = [];
    for (let m = 0; m < loaderList.length; ++m) {
      loaders.push(new loaderList[m]());
    }

    // find an appropriate loader
    let dataElement = data[0];
    let loader = null;
    let foundLoader = false;
    for (let l = 0; l < loaders.length; ++l) {
      loader = loaders[l];
      if (loader.canLoadUrl(dataElement, options)) {
        foundLoader = true;
        // load options
        loader.setOptions({
          numberOfFiles: data.length,
          defaultCharacterSet: this.getDefaultCharacterSet()
        });
        // set loader callbacks
        // loader.onloadstart: nothing to do
        loader.onprogress = mproghandler.getUndefinedMonoProgressHandler(1);
        loader.onloaditem = this.onloaditem;
        loader.onload = this.#addLoad;
        loader.onloadend = this.#addLoadend;
        loader.onerror = this.onerror;
        loader.onabort = this.onabort;

        // store loader
        this.#storeLoader(loader);
        // exit
        break;
      }
    }
    if (!foundLoader) {
      throw new Error('No loader found for url: ' + dataElement);
    }

    // store last run request index
    let lastRunRequestIndex = 0;
    const requestOnLoadEnd = () => {
      // launch next in queue
      if (lastRunRequestIndex < this.#requests.length - 1 && !this.#aborting) {
        ++lastRunRequestIndex;
        this.#requests[lastRunRequestIndex].send(null);
      }
    };

    // loop on I/O elements
    for (let i = 0; i < data.length; ++i) {
      dataElement = data[i];

      // check loader
      if (!loader.canLoadUrl(dataElement, options)) {
        throw new Error('Input url of different type: ' + dataElement);
      }
      /**
       * The http request.
       *
       * Ref: {@link https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest}.
       *
       * @external XMLHttpRequest
       */
      const request = new XMLHttpRequest();
      request.open('GET', dataElement, true);

      // request options
      if (typeof options !== 'undefined') {
        // optional request headers
        if (typeof options.requestHeaders !== 'undefined') {
          const requestHeaders = options.requestHeaders;
          for (let j = 0; j < requestHeaders.length; ++j) {
            if (typeof requestHeaders[j].name !== 'undefined' &&
              typeof requestHeaders[j].value !== 'undefined') {
              request.setRequestHeader(
                requestHeaders[j].name, requestHeaders[j].value);
            }
          }
        }
        // optional withCredentials
        // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
        if (typeof options.withCredentials !== 'undefined') {
          request.withCredentials = options.withCredentials;
        }
      }

      // set request callbacks
      // request.onloadstart: nothing to do
      request.onprogress = this.#augmentCallbackEvent(
        mproghandler.getMonoProgressHandler(i, 0), dataElement);
      request.onload = this.#getLoadHandler(loader, dataElement, i);
      request.onloadend = requestOnLoadEnd;
      const errorCallback =
        this.#augmentCallbackEvent(this.onerror, dataElement);
      request.onerror = (event) => {
        this.#addLoadend();
        errorCallback(event);
      };
      const abortCallback =
        this.#augmentCallbackEvent(this.onabort, dataElement);
      request.onabort = (event) => {
        this.#addLoadend();
        abortCallback(event);
      };
      // response type (default is 'text')
      if (loader.loadUrlAs() === urlContentTypes.ArrayBuffer) {
        request.responseType = 'arraybuffer';
      }

      // store request
      this.#storeRequest(request);
    }

    // launch requests in batch
    let batchSize = this.#requests.length;
    if (typeof options !== 'undefined') {
      // optional request batch size
      if (typeof options.batchSize !== 'undefined' && batchSize !== 0) {
        batchSize = Math.min(options.batchSize, this.#requests.length);
      }
    }
    for (let r = 0; r < batchSize; ++r) {
      if (!this.#aborting) {
        lastRunRequestIndex = r;
        this.#requests[lastRunRequestIndex].send(null);
      }
    }
  }

  /**
   * Load a DICOMDIR.
   *
   * @param {string} dicomDirUrl The DICOMDIR url.
   * @param {object} [options] Load options.
   */
  #loadDicomDir(dicomDirUrl, options) {
    // read DICOMDIR
    const request = new XMLHttpRequest();
    request.open('GET', dicomDirUrl, true);
    request.responseType = 'arraybuffer';
    // request.onloadstart: nothing to do
    /**
     * @param {object} event The load event.
     */
    request.onload = (event) => {
      // check status
      const status = event.target.status;
      if (status !== 200 && status !== 0) {
        this.onerror({
          source: dicomDirUrl,
          error: 'GET ' + event.target.responseURL +
            ' ' + event.target.status +
            ' (' + event.target.statusText + ')',
          target: event.target
        });
        this.onloadend({});
      } else {
        // get the file list
        const list = getFileListFromDicomDir(event.target.response);
        // use the first list
        const urls = list[0][0];
        // append root url
        const rootUrl = getRootPath(dicomDirUrl);
        const fullUrls = [];
        for (let i = 0; i < urls.length; ++i) {
          fullUrls.push(rootUrl + '/' + urls[i]);
        }
        // load urls
        this.#loadUrls(fullUrls, options);
      }
    };
    request.onerror = (event) => {
      this.#augmentCallbackEvent(this.onerror, dicomDirUrl)(event);
      this.onloadend({});
    };
    request.onabort = (event) => {
      this.#augmentCallbackEvent(this.onabort, dicomDirUrl)(event);
      this.onloadend({});
    };
    // request.onloadend: nothing to do
    // send request
    request.send(null);
  }

  /**
   * Abort a load.
   */
  abort() {
    this.#aborting = true;
    // abort non finished requests
    for (let i = 0; i < this.#requests.length; ++i) {
      // 0: UNSENT, 1: OPENED, 2: HEADERS_RECEIVED (send()), 3: LOADING, 4: DONE
      if (this.#requests[i].readyState !== 4) {
        this.#requests[i].abort();
      }
    }
    // abort loader
    if (this.#runningLoader && this.#runningLoader.isLoading()) {
      this.#runningLoader.abort();
    }
  }

  /**
   * Handle a load start event.
   * Default does nothing.
   *
   * @param {object} _event The load start event.
   */
  onloadstart(_event) {}

  /**
   * Handle a load progress event.
   * Default does nothing.
   *
   * @param {object} _event The progress event.
   */
  onprogress(_event) {}

  /**
   * Handle a load item event.
   * Default does nothing.
   *
   * @param {object} _event The load item event fired
   *   when a file item has been loaded successfully.
   */
  onloaditem(_event) {}

  /**
   * Handle a load event.
   * Default does nothing.
   *
   * @param {object} _event The load event fired
   *   when a file has been loaded successfully.
   */
  onload(_event) {}

  /**
   * Handle a load end event.
   * Default does nothing.
   *
   * @param {object} _event The load end event fired
   *  when a file load has completed, successfully or not.
   */
  onloadend(_event) {}

  /**
   * Handle an error event.
   * Default does nothing.
   *
   * @param {object} _event The error event.
   */
  onerror(_event) {}

  /**
   * Handle an abort event.
   * Default does nothing.
   *
   * @param {object} _event The abort event.
   */
  onabort(_event) {}

} // class UrlsLoader