import EventEmitter from 'events';
import objHash from 'object-hash';
import moment from 'moment-timezone';
import tzLookup from 'tz-lookup';
import Cache from '../cache/scopedCache.js';
import {reusePromiseForever} from '../reusePromises.js';
import {entityType} from './parkTypes.js';
import {getLiveDataErrors} from './livedata.js';
import HTTP from './http.js';
import {parseConfig} from '../configBase.js';
import {validateEntitySchedule} from './scheduledata.js';
import {addGlobalInjections} from './globalinjections.js';
/**
* Return functions available on the supplied object as an array of strings
* @param {object} obj
* @return {Array<string>} array of function names
*/
const getMethods = (obj) => {
const properties = new Set();
let currentObj = obj;
do {
Object.getOwnPropertyNames(currentObj).map((item) => properties.add(item));
} while ((currentObj = Object.getPrototypeOf(currentObj)));
return [...properties.keys()].filter((item) => typeof obj[item] === 'function');
};
/**
* Meta-programming commands
* Functions can declare tags to add extra functionality to their behaviour.
* This is a replacement for decorators until they actually exist.
*/
const metaCommands = {
// @cache|minutesToCache
cache: function (fnName, args) {
const originalFunction = this[fnName].bind(this);
this[fnName] = async (...originalFunctionArgs) => {
let funcCacheName = `metacache_${fnName}`;
if (originalFunctionArgs.length > 0) {
funcCacheName = `metacache_${fnName}_${originalFunctionArgs.map((x) => JSON.stringify(x)).join(',')}`;
}
// figure out cache time from args[0]
let cacheTime = 1000 * 60 * 5; // default to 5 minutes
if (args.length > 0) {
// look for units of time
const timeUnit = args[0].match(/^(\d+)([mhd])$/);
if (timeUnit) {
const time = parseInt(timeUnit[1], 10);
const unit = timeUnit[2];
switch (unit) {
case 'm':
cacheTime = 1000 * 60 * time;
break;
case 'h':
cacheTime = 1000 * 60 * 60 * time;
break;
case 'd':
cacheTime = 1000 * 60 * 60 * 24 * time;
break;
}
} else {
// if no unit of time is supplied, assume minutes
cacheTime = parseInt(args[0], 10) * 1000 * 60;
}
}
// replace original function with a cache wrap
return this.cache.wrap(
// try and make a unique name based on our function name
// to store in cache
funcCacheName,
// call original function if value not in cache
async () => {
return originalFunction(...originalFunctionArgs);
},
cacheTime,
);
};
},
};
/**
* The base Destination object
*/
export class Destination extends EventEmitter {
/**
* Construct a new empty Destination object
* @param {object} options
*/
constructor(options = {}) {
super();
// add class name to our $env options
options.configPrefixes = [this.constructor.name].concat(
options.configPrefixes || [],
);
// debug callback to list environment variables
if (options?.envCallback) {
const keys = Object.keys(options);
const prefix = this.constructor.name;
keys.map((x) => {
options?.envCallback(prefix, x);
});
}
// set some defaults, allowing any destination to override through .env
options.useragent = options.useragent || '';
options.cacheVersion = options.cacheVersion || 0;
// parse config from ENV by combining with options
const config = parseConfig(options);
this.config = config;
this._destinationEntityObject = null;
// create a new cache object for this destination
this.cache = new Cache(this.constructor.name, this.config.cacheVersion || 0);
this._allEntities = null;
this._allEntitiesLastStash = 0;
this.http = new HTTP();
if (this.config.useragent) {
this.http.useragent = this.config.useragent;
}
// add global injections
addGlobalInjections({
httpObject: this.http,
configPrefixes: options.configPrefixes,
});
if (!this.config.timezone) {
throw new Error(`All destination objects must have a timezone! ${this.constructor.name}`);
}
// debug log all HTTP requests
this.http.injectForDomain({hostname: {$exists: true}}, (method, url) => {
this.log(method, url);
});
// get list of all class functions for some meta programming
const funcs = getMethods(this);
funcs.forEach((funcName) => {
// skip constructor
if (funcName === 'constructor') return;
// get function as a string
const funcStr = this[funcName].toString();
// match any lines that contain only a string starting with @
// eg. '@cache()';
// TODO - match multiple times for complex meta functions
const match = /^\s*(['"`])\s*@([^'"`]+)\1;?/mg.exec(funcStr);
if (match) {
const splits = match[2].split('|');
// look for matching meta function
const metaFn = metaCommands[splits[0]];
if (!metaFn) {
return;
}
// console.log(`Setting up ${splits[0]} for ${funcName}...`);
// call meta function
metaFn.call(this, funcName, splits.slice(1));
}
});
}
/**
* Debug log
* @param {...any} args Message to debug log
*/
log(...args) {
console.log(`[\x1b[32m${this.constructor.name}\x1b[0m]`, ...args);
}
/**
* Initialise the object. Will only be initialised once
*/
async init() {
await reusePromiseForever(this, this._init);
}
/**
* Instance implementation of init, implement this function for a single-execution of init()
*/
async _init() { }
/**
* Return the current time for this destination in its local timezone
* @return {moment}
*/
getTimeNowMoment() {
return moment().tz(this.config.timezone);
}
/**
* Return the current time for this destination in its local timezone
* @return {string}
*/
getTimeNow() {
return this.getTimeNowMoment().format();
}
/**
* Set local live data cache for an entity
* @param {string} id
* @param {object} data
* @param {object} [lock] Optional lock file to use for transactions
* @private
*/
async setLiveCache(id, data, lock) {
const cache = lock ? lock : this.cache;
const cacheTime = 1000 * 60 * 60 * 24 * 30 * 6; // 6 months
await cache.set(`${id}_live`, data, cacheTime);
// generate hash and store separately
await cache.set(`${id}_livehash`, data ? objHash(data) : undefined, cacheTime);
}
/**
* Get the locally stored live data for an ID
* @param {string} id
* @param {object} [lock]
* @return {object}
*/
async getLiveCache(id, lock) {
const cache = lock ? lock : this.cache;
return cache.get(`${id}_live`);
}
/**
* Get the hash of a stored live data of an entity
* @param {string} id
* @param {object} [lock] Optional lock file to use for transactions
* @private
*/
async getLiveHash(id, lock) {
const cache = lock ? lock : this.cache;
return await cache.get(`${id}_livehash`);
}
/**
* Build a generic base entity object
* @param {object} data
* @return {object}
*/
buildBaseEntityObject(data) {
const entity = {
timezone: this.config.timezone,
};
return entity;
}
/**
* Build the destination entity representing this destination
*/
async buildDestinationEntity() {
throw new Error('buildDestinationEntity() needs an implementation', this.constructor.name);
}
/**
* Build the park entities for this destination
*/
async buildParkEntities() {
throw new Error('buildParkEntities() needs an implementation', this.constructor.name);
}
/**
* Build the attraction entities for this destination
*/
async buildAttractionEntities() {
throw new Error('buildAttractionEntities() needs an implementation', this.constructor.name);
}
/**
* Build the show entities for this destination
*/
async buildShowEntities() {
throw new Error('buildShowEntities() needs an implementation', this.constructor.name);
}
/**
* Build the restaurant entities for this destination
*/
async buildRestaurantEntities() {
throw new Error('buildRestaurantEntities() needs an implementation', this.constructor.name);
}
/**
* Get the Entity object for this destination
* @return {Object}
*/
async getDestinationEntity() {
await this.init();
// TODO - cache this?
if (!this._destinationEntityObject) {
this._destinationEntityObject = await this.buildDestinationEntity();
}
// force to array (for multi-destination destinations)
return [].concat(this._destinationEntityObject);
}
/**
* Get all entities belonging to this destination.
*/
async getAllEntities() {
const minCacheTime = +new Date() - (1000 * 60 * 5); // refresh every 5 minutes
if (this._allEntities && this._allEntitiesLastStash > minCacheTime) {
return this._allEntities;
}
// TODO - cache each of these calls for some time
// TODO - promise reuse this function
const destination = await this.getDestinationEntity();
this._allEntities = [].concat(
destination,
(await this.buildParkEntities()),
(await this.buildAttractionEntities()),
(await this.buildShowEntities()),
(await this.buildRestaurantEntities()),
);
this._allEntitiesLastStash = +new Date();
return this._allEntities;
}
/**
* Get all park entities within this destination.
*/
async getParkEntities() {
const entities = await this.getAllEntities();
return entities.filter((e) => e?.entityType === entityType.park);
}
/**
* Get all destination entities within this destination.
*/
async getDestinationEntities() {
const entities = await this.getAllEntities();
return entities.filter((e) => e?.entityType === entityType.destination);
}
/**
* Get all attraction entities within this destination.
*/
async getAttractionEntities() {
const entities = await this.getAllEntities();
return entities.filter((e) => e?.entityType === entityType.attraction);
}
/**
* Get all show entities within this destination.
*/
async getShowEntities() {
const entities = await this.getAllEntities();
return entities.filter((e) => e?.entityType === entityType.show);
}
/**
* Get all restaurant entities within this destination.
*/
async getRestaurantEntities() {
const entities = await this.getAllEntities();
return entities.filter((e) => e?.entityType === entityType.restaurant);
}
/**
* Given an internal entity ID, return it's full entity object
* @param {string} id
* @return {object}
*/
async getEntityFromId(id) {
const entities = await this.getAllEntities();
return entities.find((x) => x && x._id === id);
}
// TODO - live data API methods in destination
/**
* Update an entity with livedata
* @param {string} internalId
* @param {object} data
*/
async updateEntityLiveData(internalId, data) {
// format incoming livedata to iron our any weird sorting inconsistency
// sort showtimes by startTime
if (data?.showtimes) {
data.showtimes.sort((a, b) => {
if (!a.startTime || !b.startTime) return false;
return moment(a.startTime).unix() - moment(b.startTime).unix();
});
}
// stack up any emit events we want to send
// we will build these up inside our database transaction,
// but don't actually send them until we've released our lock
const events = [];
// get our entity doc
const entity = await this.getEntityFromId(internalId);
if (!entity) {
return;
this.emit('error', internalId, 'UNKNOWN_ENTITY_LIVEDATA', {
message: `Trying to assign live data update to unknown entity ${internalId}`,
data,
});
return;
}
// validate incoming data
const validationErrors = getLiveDataErrors(data);
if (validationErrors !== null) {
this.emit('error', internalId, 'INVALID_LIVEDATA', {
message: `Error validating incoming live data [${internalId}] ${JSON.stringify(data)}.
\t${validationErrors.map((x) => `${x.dataPath} ${x.message}`).join('\n\t')}`,
data,
});
return;
}
// lock our live data in a transaction to avoid weird conflicts
await this.cache.runTransaction(async (lock) => {
try {
// check live data hasn't changed from cache
const storedHash = await this.getLiveHash(internalId, lock);
if (storedHash) {
const newHash = data ? objHash(data) : undefined;
if (newHash === storedHash) {
// incoming data matches stored data! no change
return;
}
}
// TODO - get previous data and diff
// const existingData = await this.getLiveCache(internalId, lock);
// TODO - identify specific live data type changes
// eg. broadcast "queueupdate" changes etc.
// always push a general "liveupdate" event
events.push(['liveupdate', internalId, data]);
// store locally
await this.setLiveCache(internalId, data, lock);
} catch (e) {
console.error(e);
}
});
// emit all our events *outside* the database transaction
// so we don't block database IO any longer than we need to
events.forEach((ev) => {
this.emit(...ev);
});
}
/**
* Build all live data for entities - implement this function in child class
*/
async buildEntityLiveData() {
throw new Error('buildEntityLiveData() needs an implementation', this.constructor.name);
}
/**
* Build all schedule data for entities - implement this function in child class
* Returns array of schedule objects
*/
async buildEntityScheduleData() {
throw new Error('buildEntityScheduleData() needs an implementation', this.constructor.name);
}
/**
* Get all live data for entities
*/
async getEntityLiveData() {
await this.init();
const liveData = (await this.buildEntityLiveData()) || [];
// process all live data we generated
for (let liveDataIdx = 0; liveDataIdx < liveData.length; liveDataIdx++) {
const data = liveData[liveDataIdx];
try {
await this.updateEntityLiveData(data._id, data);
} catch (e) {
// if (!e instanceof EntityNotFound) {
console.error(`Failed to apply live data to ${data._id}`);
console.error(e);
// }
// 19411262;entityType=Attraction = Pop/Art Skyliner Line
// 19404062;entityType=Attraction = Hollywood Studios Skyliner Line
// 19404065;entityType=Attraction = Epcot Skyliner Line
}
}
return liveData;
}
/**
* Get all schedules for this destination's entites
* @return {Array<object>} Array of objects containing _id and schedules
*/
async getEntitySchedules() {
await this.init();
const scheduleData = (await this.buildEntityScheduleData()) || [];
// validate schedule data
scheduleData.forEach((data, idx) => {
const errors = validateEntitySchedule(data);
if (errors) {
this.emit('error', data._id, 'INVALID_SCHEDULEDATA', {
message: `Destination returned invalid schedule data for ${data._id}`,
data: errors,
});
// clear out invalid schedule data
data.schedule = [];
} else {
// sort data by openingTime
data.schedule.sort((a, b) => {
return moment(a.openingTime).valueOf() - moment(b.openingTime).valueOf();
});
}
});
return scheduleData;
}
/**
* Clear the cache for a given meta function
* @private
* @param {string} functionName
* @param {Array<*>} args
*/
async _clearFunctionCache(functionName, args = []) {
let funcCacheName = `metacache_${functionName}`;
if (args.length > 0) {
funcCacheName = `metacache_${functionName}_${args.map((x) => JSON.stringify(x)).join(',')}`;
}
await this.cache.set(funcCacheName, null, -1);
}
/**
* Helper function to call a function, passing in a date for each date x days in the future.
* First date will be current park date.
* @param {Function} func
* @param {Number} dates
*/
async forEachUpcomingDate(func, dates = 30) {
const now = this.getTimeNowMoment();
const end = now.clone().add(dates, 'days');
const results = [];
for (; now.isSameOrBefore(end, 'day'); now.add(1, 'day')) {
const d = await func(now.clone());
if (d) {
results.push(d);
}
}
return results;
}
/**
* Get the app version of an Android package ID
* Only returns a valid response for apps tracked in the appwatch tracker service
* @param {string} packageId
* @param {string} [fallback] Fallback version to return if no version is found
* @return {string|undefined}
*/
async getAndroidAPPVersion(packageId, fallback = undefined) {
// cache 12 hours
'@cache|720';
try {
const resp = await this.http(
'GET',
`https://api.themeparks.wiki/appwatch/latest/${packageId}`,
{},
);
return resp?.body?.version || undefined;
} catch (e) {
if (fallback) {
return fallback;
} else {
throw e;
}
}
}
/**
* Given a longitude and latitude, return the timezone estimate for that location
* @param {Number} longitude
* @param {Number} latitude
* @return {string}
*/
calculateTimezone(longitude, latitude) {
return tzLookup(longitude, latitude);
}
}
export default Destination;