import moment from 'moment-timezone';
import {attractionType, boardingGroupState, entityType, queueType, scheduleType, statusType, returnTimeState} from '../parkTypes.js';
import Destination from '../destination.js';
import {getEntityID, IndexedWDWDB} from './wdwdb.js';
import {GetOverrideShows} from './override_shows.js';
let wdwDB = null;
/**
* Get a reference to the WDW database
* @return {IndexedWDWDB}
*/
export function getDatabase() {
if (!wdwDB) {
wdwDB = new IndexedWDWDB();
}
return wdwDB;
}
// entity types that count as "parks"
const parkTypes = [
'theme-park',
'water-park',
];
// scheduleTypes that are actually not a schedule
const invalidScheduleTypes = [
'Closed',
'No Performance',
];
/**
* A Resort class for a Disney live resort (WDW, DLR, HKDR)
*/
export class DisneyLiveResort extends Destination {
/**
* @inheritdoc
*/
constructor(options = {}) {
options.virtualQueueURL = options.virtualQueueURL || '';
options.genieData = options.genieData || '';
options.slug = options.slug || '';
super(options);
this.resortId = options.resortId;
if (!this.resortId) {
throw new Error('Missing Resort ID');
}
this.resortShortcode = options.resortShortcode;
if (!this.resortShortcode) {
throw new Error('Missing Resort Shortcode');
}
this.destinationId = this.config.destinationId || `${this.resortId};entityType=destination`;
this.destinationDocumentIDRegex = new RegExp(`^${this.resortId};entityType=destination`);
this.parkIds = options.parkIds || [];
this.cultureFilter = this.config.cultureFilter || undefined;
this.slug = this.config.slug || '';
this.db = getDatabase();
}
/**
* Initialise our Disney resort class
*/
async _init() {
await this.db.init();
// setup our live status updating
this.initLiveStatusUpdates();
}
/**
* Get the channel ID for the facility status live update documents
* @return {string}
*/
getFacilityStatusChannelID() {
return `${this.resortShortcode}.facilitystatus.1_0`;
}
/**
* @private
*/
async fetchVirtualQueueData() {
// cache for 1 minute
'@cache|1';
if (!this.config.virtualQueueURL) return undefined;
try {
return (await this.http('GET', this.config.virtualQueueURL, undefined, {
rejectUnauthorized: false,
})).body.queues;
} catch (e) {
console.error(e);
return undefined;
}
}
async fetchGenieData() {
// cache for 1 minute
'@cache|1';
if (!this.config.genieData) return undefined;
try {
const resp = await this.http('GET', this.config.genieData, undefined, {
rejectUnauthorized: false,
});
return resp.body?.entities;
} catch (e) {
console.error(e);
return undefined;
}
}
/**
* Given a status doc, build a live data object
* @param {object} doc
*/
async _buildLiveDataObject(doc) {
// get full facility doc
const entityId = getEntityID(doc.id || doc._id);
const entityDoc = await this.db.getEntityOne(entityId);
// if can't find it, or "soft deleted", or we get back the same facility update doc, return nothing
if (!entityDoc || entityDoc.softDeleted || entityDoc._id === doc._id) {
return undefined;
}
// get our entity type
const docEntityType = entityDoc.type;
// skip if this doc is not a valid entity type
/* if (docEntityType === undefined) {
this.emit('error', doc.id || doc._id, 'LIVEDATA_MISSING_ENTITYTYPE', {
message: `Live data for ${doc.id || doc._id} is missing a type. Expecting "Attraction", "Entertainment"...`,
entityDoc,
});
return undefined;
}*/
// figure out entity status
let status = statusType.operating;
// if name contains "Temporarily Unavailable", mark as closed
// will be overriden by better metrics later, if any exist
if (entityDoc.name && entityDoc.name.indexOf('Temporarily Unavailable') > 0) {
// not refurb, as this more often than not "unavailable" is marked as just "closed"
status = statusType.closed;
}
// restaurants can have status "Capacity", "Walk-Up Disabled"
// currently these fallback to "Operating", which matches the resturant state well enough
if (doc.status === 'Down') {
status = statusType.down;
} else if (doc.status === 'Closed') {
status = statusType.closed;
} else if (doc.status === 'Refurbishment') {
status = statusType.refurbishment;
}
// create base data object
const data = {
_id: entityDoc.id,
status: status,
};
// get our genie data
const genieData = await this.fetchGenieData();
if (genieData) {
const genieEntity = genieData.find((x) => {
return x.id === entityId;
});
if (genieEntity) {
const now = this.getTimeNowMoment();
if (genieEntity.flex) {
if (!data.queue) data.queue = {};
if (!genieEntity.flex.available) {
data.queue[queueType.returnTime] = {
returnStart: null,
returnEnd: null,
state: returnTimeState.finished,
};
} else {
const breakTime = genieEntity.flex.nextAvailableTime.split(':');
data.queue[queueType.returnTime] = {
returnStart: now.clone().set({
hours: Number(breakTime[0]),
minutes: Number(breakTime[1]),
seconds: 0,
}).format(),
returnEnd: null,
state: returnTimeState.available,
};
}
}
if (genieEntity.individual) {
if (!data.queue) data.queue = {};
if (!genieEntity.individual.available) {
data.queue[queueType.paidReturnTime] = {
returnStart: null,
returnEnd: null,
state: returnTimeState.finished,
price: genieEntity.individual?.price ? {
currency: 'USD',
amount: genieEntity.individual.price ? genieEntity.individual.price * 100 : null,
} : null,
};
} else {
const breakTime = genieEntity.individual.nextAvailableTime.split(':');
data.queue[queueType.paidReturnTime] = {
returnStart: now.clone().set({
hours: Number(breakTime[0]),
minutes: Number(breakTime[1]),
seconds: 0,
}).format(),
returnEnd: null,
state: returnTimeState.available,
price: {
currency: 'USD',
amount: genieEntity.individual.price ? genieEntity.individual.price * 100 : null,
},
};
}
}
}
}
// get our vqueue data
const vQueueData = await this.fetchVirtualQueueData();
if (vQueueData) {
// find matching queue data for this entity
const attractionVQueueData = vQueueData.find((x) => {
return x.externalDefinitionId === entityDoc.id;
});
if (attractionVQueueData) {
// we have found a virtual queue!
if (!data.queue) data.queue = {}; // make sure our queue object exists
// figure out our allocation status
// default to available
let allocationStatus = boardingGroupState.available;
if (doc.status !== 'Virtual Queue' || attractionVQueueData.state === 'CLOSED') {
allocationStatus = boardingGroupState.closed;
}
// PAUSED state
if (attractionVQueueData.state === 'PAUSED') {
// if we have an upcoming allocation time, then we are temporarily paused
if (attractionVQueueData.nextScheduledOpenTime) {
allocationStatus = boardingGroupState.paused;
} else {
// otherwise... no future times? we're closed for the day
allocationStatus = boardingGroupState.closed;
}
}
// extract allocation time and present as a full datetime string
let nextAllocationTime = null;
if (attractionVQueueData.nextScheduledOpenTime) {
const nowDate = this.getTimeNowMoment().format('YYYY-MM-DD');
nextAllocationTime = moment.tz(
`${nowDate}T${attractionVQueueData.nextScheduledOpenTime}`,
this.config.timezone,
).format();
}
// pull estimated wait data, if valid/exists
let estimatedWait = null;
if (allocationStatus === boardingGroupState.available) {
estimatedWait = attractionVQueueData.waitTimeMin || null;
}
data.queue[queueType.boardingGroup] = {
allocationStatus: allocationStatus,
currentGroupStart: attractionVQueueData.currentArrivingGroupStart || null,
currentGroupEnd: attractionVQueueData.currentArrivingGroupEnd || null,
nextAllocationTime: nextAllocationTime || null,
estimatedWait,
};
}
}
// inject prediction data from Genie
const forecastDocId = `${this.resortShortcode}.forecastedwaittimes.1_0.en_us.${data._id}`;
try {
const forecastDoc = await this.db.get(forecastDocId);
if (forecastDoc && forecastDoc.forecasts && forecastDoc.forecasts.length > 0) {
// check forecast data is relevant for current time
// get the largest timestamp from forecast data
const now = this.getTimeNowMoment();
const lastTimeslot = forecastDoc.forecasts.reduce((prev, curr) => {
if (!curr) return prev;
if (prev && prev.timestamp > curr.timestamp) {
return prev;
}
return curr;
});
if (lastTimeslot && lastTimeslot.timestamp) {
// forecasts are in hour slots, so add an hour to the last timestamp
const lastHour = moment(lastTimeslot.timestamp).add(1, 'hour');
if (lastHour.isAfter(now)) {
// we have a valid forecast for today, return it
data.forecast = forecastDoc.forecasts.map((x) => {
if (!x) return null;
return {
time: moment(x.timestamp).tz(this.config.timezone).format(),
waitTime: isNaN(x.forecastedWaitMinutes) ? null : x.forecastedWaitMinutes,
percentage: isNaN(x.percentage) ? null : x.percentage,
};
}).filter((x) => !!x);
}
}
}
} catch (e) { }
// add any data from daily Entertainment feed
if (docEntityType === 'Entertainment') {
// grab entity showtimes
// TODO - this is expensive to grab this for every single entity!!!
const entertainmentToday = await this.db.get(`${this.resortShortcode}.today.1_0.Entertainment`);
if (entertainmentToday && entertainmentToday.facilities) {
const showtimes = (entertainmentToday.facilities[doc.id] || []).filter((x) => {
// ignore invalid schedule types
return invalidScheduleTypes.indexOf(x.scheduleType) < 0;
});
if (showtimes.length === 0) {
data.status = statusType.closed;
}
// add showtimes to livedata
data.showtimes = showtimes.map((time) => {
return {
startTime: moment(time.startTime).tz(this.config.timezone).format(),
endTime: moment(time.endTime).tz(this.config.timezone).format(),
type: time.scheduleType,
};
});
// sort showtimes by start time
data.showtimes.sort((a, b) => {
if (a.startTime < b.startTime) return -1;
if (a.startTime > b.startTime) return 1;
return 0;
});
}
} else if (docEntityType === 'restaurant') {
// TODO - restaurant specific live data
} else if (docEntityType === 'Attraction') {
// attraction-specific live data
// check today's schedule for refurbishments!
// TODO - this is expensive to grab for every single entity!
const attractionsToday = await this.db.get(`${this.resortShortcode}.today.1_0.Attraction`);
if (attractionsToday !== undefined && attractionsToday.facilities) {
const attractionSchedule = attractionsToday.facilities[doc.id];
if (attractionSchedule) {
// look for schedules with "Closed" or "Refurb"
if (attractionSchedule.length === 1) {
if (attractionSchedule[0].scheduleType === 'Closed') {
data.status = statusType.closed;
} else if (attractionSchedule[0].scheduleType === 'Refurbishment') {
data.status = statusType.refurbishment;
}
}
// store attraction operating hours in live data
data.operatingHours = [];
// loop over attractionSchedule
for (const schedule of attractionSchedule) {
if (!schedule.isClosed && schedule.startTime && schedule.endTime) {
data.operatingHours.push({
startTime: moment(schedule.startTime).tz(this.config.timezone).format(),
endTime: moment(schedule.endTime).tz(this.config.timezone).format(),
type: schedule.scheduleType,
});
}
}
// sort data.operatingHours by startTime
data.operatingHours.sort((a, b) => {
if (a.startTime < b.startTime) return -1;
if (a.startTime > b.startTime) return 1;
return 0;
});
}
}
}
// before we do any queue stuff, check if the lastUpdate is vaguely recent
const lastUpdateTime = moment(doc.lastUpdate || 0);
const now = moment();
// if status was updated in past ~2 months, then push queue data
// otherwise, ignore, queues not used
const daysSinceLastUpdate = now.diff(lastUpdateTime, 'days');
if (daysSinceLastUpdate < 60) {
// report wait minutes for standBy line (if present)
// pretty much any entity can have waitMinutes
// ignore if doc status is "Virtual Queue", which means only Virtual Queue is available for this attraction (right now)
if (doc.waitMinutes !== undefined && doc.status !== 'Virtual Queue') {
if (!data.queue) data.queue = {};
data.queue[queueType.standBy] = {
waitTime: doc.waitMinutes || null,
};
}
// populate the single ride queue status if this ride offers single rider
if (doc.singleRider) {
if (!data.queue) data.queue = {};
data.queue[queueType.singleRider] = {
// TODO - can we get single ride wait time?
waitTime: null,
};
}
}
return data;
}
/**
* Return all current live entity data
*/
async buildEntityLiveData() {
// fetch the current attraction times
const allStatusDocs = await this.db.getByChannel(this.getFacilityStatusChannelID());
const docs = [];
for (let i = 0; i < allStatusDocs.length; i++) {
const doc = allStatusDocs[i];
const liveDoc = await this._buildLiveDataObject(doc);
if (liveDoc) {
docs.push(liveDoc);
}
}
// TEMPORARY - Guardians of the Galaxy is missing from live data
// so add it manually to get some data through
/*const isGotgAlreadyIn = docs.find((x) => x._id === '411499845;entityType=Attraction');
if (!isGotgAlreadyIn) {
docs.push(await this._buildLiveDataObject({
id: '411499845;entityType=Attraction',
status: 'Closed',
lastUpdate: "2022-05-24T13:57:46.153Z",
}));
}*/
// loop over entertainment and pretend we have facility update docs for them
const entertainmentToday = await this.db.get(`${this.resortShortcode}.today.1_0.Entertainment`);
if (entertainmentToday && entertainmentToday.facilities) {
const entertainmentEntities = Object.keys(entertainmentToday.facilities);
for (let i = 0; i < entertainmentEntities.length; i++) {
const facId = entertainmentEntities[i];
// look for existing live data doc that has a facility status entry
const liveDataIdx = docs.findIndex((x) => x._id === facId);
if (liveDataIdx < 0) {
// if we don't have a doc already, we create a "fake" one
// build a pretend facilitystatus doc and push to docs
const liveData = await this._buildLiveDataObject({
id: facId,
});
if (liveData) {
docs.push(liveData);
}
}
}
}
// build pretend live entity objects for any attractions with schedules (but no live data!)
const attractionsToday = await this.db.get(`${this.resortShortcode}.today.1_0.Attraction`);
if (attractionsToday !== undefined && attractionsToday.facilities) {
const attractionsEntities = Object.keys(attractionsToday.facilities);
for (let i = 0; i < attractionsEntities.length; i++) {
const facId = attractionsEntities[i];
// look for existing live data doc that has a facility status entry
const liveDataIdx = docs.findIndex((x) => x._id === facId);
if (liveDataIdx < 0) {
const liveData = await this._buildLiveDataObject({
id: facId,
});
if (liveData) {
docs.push(liveData);
}
}
}
}
// TODO - do something with invalid objects (?!)
// const errors = docs.filter((x) => x.status !== 'fulfilled');
return docs;
}
/**
* Setup our live status update subscriptions
*/
async initLiveStatusUpdates() {
// subscribe to any live facility status updates
this.db.subscribeToChannel(this.getFacilityStatusChannelID(), async (doc) => {
// create our live data object and submit to resort
const livedata = await this._buildLiveDataObject(doc);
if (!livedata) return; // skip any invalid livedata objects
try {
this.updateEntityLiveData(doc.id, livedata);
} catch (e) {
console.error(e);
}
});
}
/**
* Given a name for an entity, clean up any strings we don't want
* @param {string} name
* @return {string}
*/
sanitizeEntityName(name) {
let newName = `${name}`;
// trim any name endings we don't want to transfer to our entity object
const cutoffExcessiveNameEndings = [
' – Opens',
' - Opens',
' – Reopening',
' - Reopening',
' – Temporarily Unavailable',
' - Temporarily Unavailable',
' – Temporarily ',
' - Temporarily ',
' – Coming ',
' - Coming ',
' – Legacy Passholder Dining',
' - Legacy Passholder Dining',
' – Opening ',
' - Opening ',
' - Returning',
' – Returning',
' – Now Open!',
' - New!',
' – New!',
];
cutoffExcessiveNameEndings.forEach((str) => {
const substrFound = newName.indexOf(str);
if (substrFound > 0) {
newName = newName.slice(0, substrFound);
}
});
return newName;
}
/**
* Given a basic document build a generic entity doc.
* This should include all fields that are in any entity type.
* @param {object} doc
* @return {object}
*/
buildBaseEntityObject(doc) {
const entity = {
// add any resort-agnostic data from the parent first
...super.buildBaseEntityObject(doc),
_id: doc.id,
_docId: doc._id,
name: this.sanitizeEntityName(doc.name),
};
if (doc.longitude && doc.latitude) {
entity.location = {
longitude: Number(doc.longitude),
latitude: Number(doc.latitude),
// TODO - return entrance/exit/shop/etc. interesting points
// that aren't neccessarily the "main location"
pointsOfInterest: [],
};
}
// search for any related locations that is a theme-park - this tells us this entity is within this park!
// set this so we can correctly build our entity heirarchy
// skip if our type is actuall "theme-park", as parks aren't parented to themselves
if (
parkTypes.indexOf(doc.type) < 0 &&
doc.relatedLocations &&
doc.relatedLocations.length > 0 &&
doc.relatedLocations[0].ancestors
) {
// try to find parkId (if it exists)
const park = doc.relatedLocations[0].ancestors.find((x) => {
return parkTypes.indexOf(x.type) >= 0;
});
if (park) {
entity._parkId = park.id;
}
}
// tags
if (doc.facets) {
if (doc.facets.find((x) => x.id === 'expectant-mothers')) {
entity.unsuitableForPregnantPeople = true;
}
}
// TODO - rename so something park-agnostic?
if (doc.fastPassPlus !== undefined) {
entity.fastPass = !!doc.fastPassPlus;
}
// if we're not inside a park, parent ourselves to the *something*
const nonParkParentPriority = [
'theme-park',
'water-park',
'Entertainment-Venue', // eg. Disney Springs
'destination',
];
// look through list in order until we find an entity we can attach to
let parentDoc;
if (doc.relatedLocations && doc.relatedLocations.length > 0 && doc.relatedLocations[0].ancestors) {
for (let parentTypeIdx = 0; parentTypeIdx < nonParkParentPriority.length; parentTypeIdx++) {
const parentType = nonParkParentPriority[parentTypeIdx];
parentDoc = doc.relatedLocations[0].ancestors.find((x) => {
return x.type === parentType && x.id !== doc.relatedLocations[0].id;
});
if (parentDoc) break;
}
if (parentDoc) {
entity._parentId = parentDoc.id;
}
}
return entity;
}
/**
* Return entity document for this destination
*/
async buildDestinationEntity() {
const resortIndex = await this.db.getEntityIndex(this.resortId, {
entityType: 'destination',
});
if (resortIndex.length === 0) return undefined;
const doc = await this.db.get(resortIndex[0]._id);
return {
...this.buildBaseEntityObject(doc),
entityType: entityType.destination,
slug: this.config.slug == '' ? doc.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() : this.config.slug,
};
}
/**
* Return all park entities for this resort
*/
async buildParkEntities() {
const parkData = (await Promise.all(this.parkIds.map(async (parkID) => {
return this.db.getEntityOne(parkID);
}))).filter((x) => !!x);
return parkData.map((park) => {
return {
...this.buildBaseEntityObject(park),
entityType: entityType.park,
_destinationId: this.destinationId,
// parks are parented to the resort
_parentId: this.destinationId,
};
});
}
/**
* @inheritdoc
*/
async buildAttractionEntities() {
const search = {
type: 'Attraction',
relatedLocations: {
$elemMatch: {
ancestors: {
$elemMatch: {
id: {
$regex: this.destinationDocumentIDRegex,
},
},
},
},
},
// ignore any attractions with zero facets
facets: {
$exists: true,
},
};
// optional culture filter
// hkdl has multiple versions of all entities
if (this.config.cultureFilter) {
search._id = {
$regex: new RegExp(this.config.cultureFilter),
};
}
const attractions = await this.db.find(search);
// filter out known bad names
const ignoreAttractions = [
/^Disney Park Pass$/,
/Park Pass \- Afternoon$/,
/Play Disney Parks/,
/^Temporarily Unavailable Entertainment/,
];
const entities = attractions.filter((attr) => {
return !ignoreAttractions.find((x) => {
return !!attr.name.match(x);
});
}).map((attraction) => {
// turn into entity objects
// TODO - add extra meta data to entity objects
let type = attractionType.unknown;
const hasFacet = (facet) => {
if (!attraction?.facets) return false;
return !!attraction.facets.find((x) => {
return x.id === facet;
});
};
// figure out ride type from available facets...
if (
hasFacet('slow-rides') ||
hasFacet('small-drops') ||
hasFacet('thrill-rides') ||
hasFacet('spinning')
) {
type = attractionType.ride;
}
return {
...this.buildBaseEntityObject(attraction),
_destinationId: this.destinationId,
entityType: entityType.attraction,
attractionType: type,
};
});
return entities;
}
/**
* @inheritdoc
*/
async buildShowEntities() {
// filter out known bad names
const ignoreShows = [
/^Temporarily Unavailable Entertainment/,
];
const search = {
type: 'Entertainment',
relatedLocations: {
$elemMatch: {
ancestors: {
$elemMatch: {
id: {
$regex: this.destinationDocumentIDRegex,
},
},
},
},
},
};
// optional culture filter
// hkdl has multiple versions of all entities
if (this.config.cultureFilter) {
search._id = {
$regex: new RegExp(this.config.cultureFilter),
};
}
return (await this.db.find(search)).filter((show) => {
return !ignoreShows.find((x) => {
return !!show.name.match(x);
});
}).concat(GetOverrideShows()).map((re) => {
return {
...this.buildBaseEntityObject(re),
_destinationId: this.destinationId,
entityType: entityType.show,
};
});
}
/**
* Fetch restaurant menu JSON
* @param {string} id
* @private
*/
async _fetchRestaurantMenu(id) {
// TODO - implement a separate menu service
// this API returns errors *a lot*, so we should fetch on a very gentle cycle and cache heavily
return null;
return this.cache.wrap(
`menu_${id}`,
async () => {
try {
const data = await this.http(
'GET',
`https://dining-menu-svc.wdprapps.disney.com/diningMenuSvc/orchestration/menus/${id}`,
null,
{
retries: 0,
},
);
if (data && data.body) {
return data.body;
}
} catch (e) { }
return null;
},
1000 * 60 * 60 * 6, // cache for 6 hours
);
}
/**
* Get the menu for a given resturant entity ID
* @param {string} id
*/
async getRestaurantMenu(id) {
try {
const menu = await this._fetchRestaurantMenu(id);
if (!menu) return undefined;
const menuData = menu.menus.map((menuGroup) => {
if (!menuGroup.menuGroups) {
return undefined;
}
const items = [];
let groupPrice = null;
// WDW menus are split into Entree,Desert etc. "menuGroups" - loop through them all and build them into a list
menuGroup.menuGroups.forEach((group) => {
// look for buffet pricings
// extract characters and digits to find pricing categories
const findBuffetPrices = /([^\/\(]+\d+\.\d+)/g;
let match;
while (match = findBuffetPrices.exec(group.names.PCLong)) {
// split each buffet price into name and USD
const nameAndPrice = /(.*)\s+(\d+\.\d+)/;
const priceData = nameAndPrice.exec(match[1]);
if (priceData) {
if (groupPrice === null) {
groupPrice = [];
}
// add each unique price name once
const priceName = priceData[1].trim();
if (groupPrice.findIndex((x) => x.name === priceName) < 0) {
groupPrice.push({
name: priceData[1].trim(),
USD: Number(priceData[2]) * 100,
});
}
}
}
group.menuItems.forEach((dish) => {
const newDish = {
name: dish.names.PCLong || dish.names.MobileLong || dish.names.PCShort || dish.names.MobileShort || null,
description: dish?.descriptions?.PCLong?.text ||
dish?.descriptions?.MobileLong?.text ||
dish?.descriptions?.MobileShort?.text ||
null,
group: group.menuGroupType,
price: null,
};
if (dish?.prices?.PerServing?.withoutTax) {
// standard per-serving prices
newDish.price = [{
USD: dish.prices.PerServing.withoutTax * 100,
}];
} else if (dish.prices) {
// not per serving price
newDish.price = Object.keys(dish.prices).map((x) => {
return {
name: dish.prices[x].type,
USD: dish.prices[x].withoutTax * 100,
};
});
}
items.push(newDish);
});
});
return {
type: menuGroup.menuType,
description: `${menuGroup.primaryCuisineType} - ${menuGroup.serviceStyle} - ${menuGroup.experienceType}`,
items,
price: groupPrice, // a menu can have a price (buffets etc.)
};
}).filter((x) => !!x);
return menuData;
} catch (e) {
console.error(e);
}
return undefined;
}
/**
* @inheritdoc
*/
async buildRestaurantEntities() {
const search = {
type: 'restaurant',
relatedLocations: {
$elemMatch: {
ancestors: {
$elemMatch: {
id: {
$regex: this.destinationDocumentIDRegex,
},
},
},
},
},
};
// optional culture filter
// hkdl has multiple versions of all entities
if (this.config.cultureFilter) {
search._id = {
$regex: new RegExp(this.config.cultureFilter),
};
}
const restaurants = (await this.db.find(search)).filter((restaurant) => {
// only include "proper" resturants
// avoid listing every coffee stand etc.
// determine resturant type using available facets
if (!restaurant.facets) return false;
const tableService = restaurant.facets.find((x) => x.id === 'table-service');
// some resturants are missing the 'table-service' facet, but have other facets that are similar
const tableReservations = restaurant.facets.find((x) => x.id === 'reservations-accepted');
// TODO - also add quick service
return !!tableService || !!tableReservations;
}).map((re) => {
// TODO - populate with any other interesting restaurant detail
return {
...this.buildBaseEntityObject(re),
_destinationId: this.destinationId,
entityType: entityType.restaurant,
// list of available cuisines
cuisines: re.facets.filter((x) => x.group === 'cuisine').map((x) => {
return x.name;
}),
};
});
// fetch menus
/* for (let i = 0; i < restaurants.length; i++) {
restaurants[i].menus = (await this.getRestaurantMenu(restaurants[i]._id)) || null;
}*/
return restaurants;
}
/**
*
* @param {array<string>} ids document IDs to get schedules for
* @param {moment} date Moment date to get schedule data for
* @return {array<object>} Array of objects containing _id and schedule
*/
async _getSchedulesForDate(ids, date) {
const dateCalendar = await this.db.getByChannel(
`${this.config.resortShortcode}.calendar.1_0`,
{
'id': date.format('DD-MM'),
},
);
if (dateCalendar.length === 0) {
return [];
}
const calendar = dateCalendar[0];
const hours = calendar.parkHours.filter((h) => {
// filter for hours for any of our parks
return ids.indexOf(h.facilityId) >= 0 &&
// that aren't closed hours (just ignore these)
h.scheduleType !== 'Closed' &&
// ignore annual pass blockout data
h.scheduleType.indexOf('blockout') < 0;
}).reduce((p, x) => {
let hoursType = scheduleType.operating;
switch (x.scheduleType) {
case 'Operating':
hoursType = scheduleType.operating;
break;
case 'Park Hopping':
hoursType = scheduleType.informational;
break;
case 'Refurbishment':
// return refurbishment hours as purely informational
hoursType = scheduleType.informational;
break;
default:
// default to a ticketed event
hoursType = scheduleType.ticketed;
break;
}
p[ids.indexOf(x.facilityId)].schedule.push({
date: moment(x.startTime).tz(this.config.timezone).format('YYYY-MM-DD'),
openingTime: moment(x.startTime).tz(this.config.timezone).format(),
closingTime: moment(x.endTime).tz(this.config.timezone).format(),
type: hoursType,
description: hoursType != scheduleType.operating ? x.scheduleType : undefined,
});
return p;
}, ids.map((x) => {
return {
_id: x,
schedule: [],
};
}));
return hours;
}
/**
* @inheritdoc
*/
async buildEntityScheduleData() {
// grab park IDs!
const parks = await this.getParkEntities();
const parkIds = parks.map((x) => {
return x._id;
});
// grab schedules for our parks
const daysToReturn = 150;
const now = this.getTimeNowMoment();
const endDate = now.clone().add(daysToReturn, 'day');
const returnData = parkIds.map((x) => {
return {
_id: x,
schedule: [],
};
});
for (; now.isSameOrBefore(endDate, 'day'); now.add(1, 'day')) {
const dateData = await this._getSchedulesForDate(parkIds, now);
dateData.forEach((entity) => {
returnData[parkIds.indexOf(entity._id)].schedule.push(...entity.schedule);
});
}
return returnData;
}
};
/**
* Walt Disney World Resort
*/
export class WaltDisneyWorldResort extends DisneyLiveResort {
/**
* @inheritdoc
*/
constructor(options = {}) {
options.name = options.name || 'Walt Disney World Resort';
options.timezone = options.timezone || 'America/New_York';
options.resortId = options.resortId || 80007798;
options.resortShortcode = options.resortShortcode || 'wdw';
options.parkIds = options.parkIds || [
80007944, // Magic Kingdom
80007838, // Epcot
80007998, // Hollywood Studios
80007823, // Animal Kingdom
// water parks
80007981, // Typhoon Lagoon
80007834, // Blizzard Beach
];
super(options);
}
}
/**
* Disneyland Resort
*/
export class DisneylandResort extends DisneyLiveResort {
/**
* @inheritdoc
*/
constructor(options = {}) {
options.name = options.name || 'Disneyland Resort';
// TODO - calculate this from resort entity's location
options.timezone = options.timezone || 'America/Los_Angeles';
options.resortId = options.resortId || 80008297;
options.resortShortcode = options.resortShortcode || 'dlr';
options.parkIds = options.parkIds || [
330339, // Disneyland Park
336894, // California Adventure
];
super(options);
}
}
/**
* Hong Kong Disneyland Resort
*/
export class HongKongDisneyland extends DisneyLiveResort {
/**
* @inheritdoc
*/
constructor(options = {}) {
options.name = options.name || 'HongKong Disneyland';
// TODO - calculate this from resort entity's location
options.timezone = options.timezone || 'Asia/Hong_Kong';
options.resortId = options.resortId || 'hkdl';
options.destinationId = options.destinationId || 'hkdl;entityType=destination;destination=hkdl';
options.resortShortcode = options.resortShortcode || 'hkdl';
options.cultureFilter = '\.en_intl\.';
options.parkIds = options.parkIds || [
'desHongKongDisneyland',
];
options.slug = 'hongkongdisneylandpark';
super(options);
}
/**
* HK stores menus as PDFs, this function does nothing (yet?)
* @return {null}
*/
async _fetchRestaurantMenu() {
return null;
}
}