parks/entity.js

import ConfigBase from '../configBase.js';
import moment from 'moment-timezone';
import zlib from 'zlib';
import util from 'util';
import HTTP from './http.js';

const zDecompress = util.promisify(zlib.unzip);
const zCompress = util.promisify(zlib.deflate);

/**
 * A super-class that Parks/Resorts/etc. inherit from.
 * Handles general logic for objects that are a place/entity.
 */
export class Entity extends ConfigBase {
  /**
   * Construct a new Entity
   * @param {object} options
   */
  constructor(options = {}) {
    // offline mode, never request any data, rely on manually serialised data to run
    options.offline = options.offline || false;

    // generate a random Android user-agent if we aren't supplied one
    options.useragent = options.useragent || null;

    super(options);

    if (!this.config.name) {
      throw new Error(`Missing name for constructed Entity object ${this.constructor.name}`);
    }

    if (!this.config.timezone) {
      throw new Error(`Missing timezone for constructed Entity object ${this.constructor.name}`);
    }
    if (moment.tz.names().indexOf(this.config.timezone) < 0) {
      throw new Error(`Entity object ${this.constructor.name} gives an invalid timezone: ${this.config.timezone}`);
    }

    this.http = new HTTP();
    if (this.config.useragent) {
      this.http.useragent = this.config.useragent;
    }

    // debug log all HTTP requests
    this.http.injectForDomain({hostname: {$exists: true}}, (method, url) => {
      this.log(method, url);
    });

    // offline function data
    this._offlineFunctions = [];
    this._offlineData = {};
    this._hasOfflineData = false;
    this._offlinePromise = null;
    this._offlinePromiseResolve = null;
  }

  /**
   * Debug log
   * @param  {...any} args Message to debug log
   */
  log(...args) {
    console.log(`[\x1b[32m${this.getUniqueID()}\x1b[0m]`, ...args);
  }

  /**
   * Get a globally unique ID for this entity
   * @return {string}
   */
  getUniqueID() {
    // by default, return the class name
    return this.constructor.name;
  }

  /**
   * Return the current time for this entity in its local timezone
   * @return {moment}
   */
  getTimeNowMoment() {
    return moment().tz(this.config.timezone);
  }

  /**
   * Return the current time for this entity in its local timezone
   * @return {string}
   */
  getTimeNow() {
    return this.getTimeNowMoment().format();
  }

  /**
   * Get entity's human-friendly name string
   * @return {string}
   */
  get name() {
    return this.config.name;
  }

  /**
   * Is this object operating offline?
   */
  get offline() {
    return !!this.config.offline;
  }

  /**
   * Register a function on this entity for offline access
   * @param {string} functionName
   */
  registerOfflineFunction(functionName) {
    if (this[functionName] && typeof this[functionName] === 'function') {
      if (this._offlineFunctions.indexOf(functionName) < 0) {
        this._offlineFunctions.push(functionName);

        // if we're in offline mode...
        if (this.offline) {
          // override function and restore from our data cache instead
          this[functionName] = async () => {
            await this.ensureHasOfflineData();

            if (this._offlineData[functionName] !== undefined) {
              return this._offlineData[functionName];
            }
            return undefined;
          };
        }
      }
    }
  }

  /**
   * Called after loading serialised offline data
   */
  async _postOfflineLoad() {}

  /**
   * Serialise this entity
   * @param {object} bundle Bundle to read/write from/to
   * @param {boolean} saving Whether we are saving or loading during this serialise operation
   * @param {object} [options]
   * @param {number} [options.version=1] Version of the seialised data
   * @param {boolean} [options.recursive=true] Recurse through attached entities?
   */
  async serialise(bundle = {}, saving = true, options = {
    version: 1,
    recursive: true,
  }) {
    if (saving) {
      // === Saving ===
      // default options
      bundle.version = options.version;
      bundle.ar = {
        functions: {},
        children: [],
      };

      // loop over all offline functions and store their data
      for (let i=0; i<this._offlineFunctions.length; i++) {
        const functionName = this._offlineFunctions[i];
        bundle.ar.functions[functionName] = await this[functionName]();
      }

      // TODO - loop over child entities, call serialise on them, then store their bundle.ar in ours
    } else {
      // === Loading ===
      // decompress/load the data
      const bundleBuffer = await zDecompress(bundle);
      bundle = JSON.parse(bundleBuffer.toString('utf8'));

      const version = bundle.version || 0;
      // check we understand this bundle version
      if (version !== 1) {
        throw new Error('Unable to load serialised bundle version', version);
      }

      // restore function data
      this._offlineData = bundle.ar.functions;

      // TODO - restore child entities
    }

    if (saving) {
      // pack and gz to buffer
      const bundleData = JSON.stringify(bundle);
      return await zCompress(bundleData);
    }

    // after loading, run any postUpdate functions
    this._postOfflineLoad();

    this._hasOfflineData = true;

    // check if any process if waiting for offline data to be ready
    if (this._offlinePromiseResolve !== null) {
      this._offlinePromiseResolve();
      this._offlinePromiseResolve = null;
      this._offlinePromise = null;
    }
  }

  /**
   * Await until offline data is present
   */
  async ensureHasOfflineData() {
    if (this.offline && !this._hasOfflineData) {
      if (this._offlinePromise === null) {
        this._offlinePromise = new Promise((resolve) => {
          this._offlinePromiseResolve = resolve;
        });
      }
      return this._offlinePromise;
    }
  }

  /**
   * Register a new injection for a specific domain
   * @param {object} filter Mongo-type query to use to match a URL
   * @param {function} func Function to call with needle request to inject extra data into.
   * Function will take arguments: (method, URL, data, options)
   */
  async injectForDomain(filter, func) {
    this.http.injectForDomain(filter, func);
  }
}

export default Entity;