chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Module: Calendar
The `calendar` module is one of the default modules of the MagicMirror².
This module displays events from a public .ical calendar. It can combine multiple calendars.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html).
+15
View File
@@ -0,0 +1,15 @@
.calendar .symbol {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 5px;
}
.calendar .title {
padding: 0 10px;
}
.calendar .time {
padding-left: 20px;
text-align: right;
}
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
const ical = require("node-ical");
const Log = require("logger");
const CalendarFetcherUtils = require("./calendarfetcherutils");
const HTTPFetcher = require("#http_fetcher");
/**
* CalendarFetcher - Fetches and parses iCal calendar data
* Uses HTTPFetcher for HTTP handling with intelligent error handling
* @class
*/
class CalendarFetcher {
/**
* Creates a new CalendarFetcher instance
* @param {string} url - The URL of the calendar to fetch
* @param {number} reloadInterval - Time in ms between fetches
* @param {string[]} excludedEvents - Event titles to exclude
* @param {number} maximumEntries - Maximum number of events to return
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
* @param {boolean} includePastEvents - Whether to include past events
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
*/
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
this.url = url;
this.excludedEvents = excludedEvents;
this.maximumEntries = maximumEntries;
this.maximumNumberOfDays = maximumNumberOfDays;
this.includePastEvents = includePastEvents;
this.events = [];
this.lastFetch = null;
this.fetchFailedCallback = () => {};
this.eventsReceivedCallback = () => {};
// Use HTTPFetcher for HTTP handling (Composition)
this.httpFetcher = new HTTPFetcher(url, {
reloadInterval,
auth,
selfSignedCert
});
// Wire up HTTPFetcher events
this.httpFetcher.on("response", (response) => this.#handleResponse(response));
this.httpFetcher.on("error", (errorInfo) => this.fetchFailedCallback(this, errorInfo));
}
/**
* Handles successful HTTP response
* @param {Response} response - The fetch Response object
*/
async #handleResponse (response) {
try {
// 304 Not Modified has no body: keep previously fetched events and just re-broadcast them.
if (response.status === 304) {
this.lastFetch = Date.now();
this.broadcastEvents();
return;
}
const responseData = await response.text();
const filteredData = await CalendarFetcherUtils.preFilterICS(responseData, {
includePastEvents: this.includePastEvents,
maximumNumberOfDays: this.maximumNumberOfDays
});
const parsed = await ical.async.parseICS(filteredData);
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
this.events = CalendarFetcherUtils.filterEvents(parsed, {
excludedEvents: this.excludedEvents,
includePastEvents: this.includePastEvents,
maximumEntries: this.maximumEntries,
maximumNumberOfDays: this.maximumNumberOfDays
});
this.lastFetch = Date.now();
this.broadcastEvents();
} catch (error) {
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
this.fetchFailedCallback(this, {
message: `iCal parsing failed: ${error.message}`,
status: null,
errorType: "PARSE_ERROR",
translationKey: "MODULE_ERROR_UNSPECIFIED",
retryAfter: this.httpFetcher.reloadInterval,
retryCount: 0,
url: this.url,
originalError: error
});
}
}
/**
* Starts fetching calendar data
*/
fetchCalendar () {
this.httpFetcher.startPeriodicFetch();
}
/**
* Check if enough time has passed since the last fetch to warrant a new one.
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
* @returns {boolean} True if a new fetch should be performed
*/
shouldRefetch () {
if (!this.lastFetch) {
return true;
}
const timeSinceLastFetch = Date.now() - this.lastFetch;
return timeSinceLastFetch >= this.httpFetcher.reloadInterval;
}
/**
* Broadcasts the current events to listeners
*/
broadcastEvents () {
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
this.eventsReceivedCallback(this);
}
/**
* Sets the callback for successful event fetches
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
*/
onReceive (callback) {
this.eventsReceivedCallback = callback;
}
/**
* Sets the callback for fetch failures
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
*/
onError (callback) {
this.fetchFailedCallback = callback;
}
}
module.exports = CalendarFetcher;
@@ -0,0 +1,306 @@
/**
* @external Moment
*/
const moment = require("moment-timezone");
const ical = require("node-ical");
const Log = require("logger");
const CalendarFetcherUtils = {
/**
* Determine based on the title of an event if it should be excluded from the list of events
* @param {object} config the global config
* @param {string} title the title of the event
* @returns {object} excluded: true if the event should be excluded, false otherwise
* until: the date until the event should be excluded.
*/
shouldEventBeExcluded (config, title) {
for (const filterConfig of config.excludedEvents) {
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
if (match) {
return {
excluded: !match.until,
until: match.until
};
}
}
return {
excluded: false,
until: null
};
},
/**
* Get local timezone.
* This method makes it easier to test if different timezones cause problems by changing this implementation.
* @returns {string} timezone
*/
getLocalTimezone () {
return moment.tz.guess();
},
/**
* Calculate the time window of events to keep, as [start, end].
* Without includePastEvents the window starts now; otherwise it also
* reaches maximumNumberOfDays into the past.
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
* @returns {[Date, Date]} The start and end of the window.
*/
calculateFilterWindow (config) {
const today = moment().startOf("day");
const start = config.includePastEvents
? today.clone().subtract(config.maximumNumberOfDays, "days").toDate()
: new Date();
const end = today.clone().add(config.maximumNumberOfDays, "days").toDate();
return [start, end];
},
/**
* Drop ICS data outside the configured time window before it is parsed,
* so that node-ical only has to process events we might actually show.
* @param {string} rawICS The raw ICS text.
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
* @returns {Promise<string>} The filtered ICS text.
*/
async preFilterICS (rawICS, config) {
// ics-filter is ESM-only, so we import it dynamically from this CommonJS file.
const { icsFilter } = await import("ics-filter");
const [start, end] = CalendarFetcherUtils.calculateFilterWindow(config);
return icsFilter(rawICS, start, end);
},
/**
* Filter the events from ical according to the given config
* @param {object} data the calendar data from ical
* @param {object} config The configuration object
* @returns {object[]} the filtered events
*/
filterEvents (data, config) {
const newEvents = [];
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
const now = moment();
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
const futureLocalMoment
= now
.clone()
.startOf("day")
.add(config.maximumNumberOfDays, "days")
// Subtract 1 second so that events that start on the middle of the night will not repeat.
.subtract(1, "seconds");
Object.values(data).forEach((event) => {
if (event.type !== "VEVENT") {
return;
}
const title = CalendarFetcherUtils.getTitleFromEvent(event);
Log.debug(`title: ${title}`);
// Return quickly if event should be excluded.
const { excluded, until: eventFilterUntil } = CalendarFetcherUtils.shouldEventBeExcluded(config, title);
if (excluded) {
return;
}
Log.debug(`Event: ${title} | start: ${event.start} | end: ${event.end} | recurring: ${!!event.rrule}`);
const location = CalendarFetcherUtils.unwrapParameterValue(event.location) || false;
const geo = event.geo || false;
const description = CalendarFetcherUtils.unwrapParameterValue(event.description) || false;
let instances;
try {
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment);
} catch (error) {
Log.error(`Could not expand event "${title}": ${error.message}`);
return;
}
for (const instance of instances) {
const { event: instanceEvent, startMoment, endMoment, isRecurring, isFullDay } = instance;
// Filter logic
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
continue;
}
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
continue;
}
const instanceTitle = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
Log.debug(`saving event: ${instanceTitle}, start: ${startMoment.toDate()}, end: ${endMoment.toDate()}`);
newEvents.push({
title: instanceTitle,
startDate: startMoment.format("x"),
endDate: endMoment.format("x"),
fullDayEvent: isFullDay,
recurringEvent: isRecurring,
class: event.class,
firstYear: event.start.getFullYear(),
location: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.location) || location,
geo: instanceEvent.geo || geo,
description: CalendarFetcherUtils.unwrapParameterValue(instanceEvent.description) || description
});
}
});
newEvents.sort(function (a, b) {
return a.startDate - b.startDate;
});
return newEvents;
},
/**
* Gets the title from the event.
* @param {object} event The event object to check.
* @returns {string} The title of the event, or "Event" if no title is found.
*/
getTitleFromEvent (event) {
return CalendarFetcherUtils.unwrapParameterValue(event.summary || event.description) || "Event";
},
/**
* Extracts the string value from a node-ical ParameterValue object ({val, params})
* or returns the value as-is if it is already a plain string.
* This handles ICS properties with parameters, e.g. DESCRIPTION;LANGUAGE=de:Text.
* @param {string|object} value The raw value from node-ical
* @returns {string|object} The unwrapped string value, or the original value if not a ParameterValue
*/
unwrapParameterValue (value) {
if (value && typeof value === "object" && typeof value.val !== "undefined") {
return value.val;
}
return value;
},
/**
* Determines if the user defined time filter should apply
* @param {moment.Moment} now Date object using previously created object for consistency
* @param {moment.Moment} endDate Moment object representing the event end date
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
* @returns {boolean} True if the event should be filtered out, false otherwise
*/
timeFilterApplies (now, endDate, filter) {
if (filter) {
const until = filter.split(" "),
value = parseInt(until[0]),
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
filterUntil = moment(endDate.format()).subtract(value, increment);
return now.isBefore(filterUntil);
}
return false;
},
/**
* Determines if the user defined title filter should apply
* @param {string} title the title of the event
* @param {string} filter the string to look for, can be a regex also
* @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
* @param {string} regexFlags flags that should be applied to the regex
* @returns {boolean} True if the title should be filtered out, false otherwise
*/
titleFilterApplies (title, filter, useRegex, regexFlags) {
if (useRegex) {
let regexFilter = filter;
// Assume if leading slash, there is also trailing slash
if (filter[0] === "/") {
// Strip leading and trailing slashes
regexFilter = filter.slice(1, -1);
}
return new RegExp(regexFilter, regexFlags).test(title);
} else {
return title.includes(filter);
}
},
/**
* Expands a recurring event into individual event instances using node-ical.
* Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events.
* @param {object} event The recurring event object
* @param {moment.Moment} pastLocalMoment The past date limit
* @param {moment.Moment} futureLocalMoment The future date limit
* @returns {object[]} Array of event instances with startMoment/endMoment in the local timezone
*/
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) {
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
return ical
.expandRecurringEvent(event, {
from: pastLocalMoment.toDate(),
to: futureLocalMoment.toDate(),
includeOverrides: true,
excludeExdates: true,
expandOngoing: true
})
.map((inst) => {
let startMoment, endMoment;
if (inst.isFullDay) {
startMoment = moment.tz([inst.start.getFullYear(), inst.start.getMonth(), inst.start.getDate()], localTimezone);
endMoment = moment.tz([inst.end.getFullYear(), inst.end.getMonth(), inst.end.getDate()], localTimezone);
} else {
startMoment = moment(inst.start).tz(localTimezone);
endMoment = moment(inst.end).tz(localTimezone);
}
// Events without DTEND (e.g. reminders) get start === end from node-ical;
// extend to end-of-day so they remain visible on the calendar.
if (startMoment.valueOf() === endMoment.valueOf()) endMoment = endMoment.endOf("day");
return { event: inst.event, startMoment, endMoment, isRecurring: inst.isRecurring, isFullDay: inst.isFullDay };
});
},
/**
* Checks if an event title matches a specific filter configuration.
* @param {string} title The event title to check
* @param {string|object} filterConfig The filter configuration (string or object)
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
*/
checkEventAgainstFilter (title, filterConfig) {
let filter = filterConfig;
let testTitle = title.toLowerCase();
let until = null;
let useRegex = false;
let regexFlags = "g";
if (filter instanceof Object) {
if (typeof filter.until !== "undefined") {
until = filter.until;
}
if (typeof filter.regex !== "undefined") {
useRegex = filter.regex;
}
if (filter.caseSensitive) {
filter = filter.filterBy;
testTitle = title;
} else if (useRegex) {
filter = filter.filterBy;
testTitle = title;
regexFlags += "i";
} else {
filter = filter.filterBy.toLowerCase();
}
} else {
filter = filter.toLowerCase();
}
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
return { until };
}
return null;
}
};
if (typeof module !== "undefined") {
module.exports = CalendarFetcherUtils;
}
+128
View File
@@ -0,0 +1,128 @@
const CalendarUtils = {
/**
* Capitalize the first letter of a string
* @param {string} string The string to capitalize
* @returns {string} The capitalized string
*/
capFirst (string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
/**
* This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the
* corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input)
* it will a localeSpecification object with the system locale time format.
* @param {number} timeFormat Specifies either 12 or 24-hour time format
* @returns {moment.LocaleSpecification} formatted time
*/
getLocaleSpecification (timeFormat) {
switch (timeFormat) {
case 12: {
return { longDateFormat: { LT: "h:mm A" } };
}
case 24: {
return { longDateFormat: { LT: "HH:mm" } };
}
default: {
return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } };
}
}
},
/**
* Shortens a string if it's longer than maxLength and add an ellipsis to the end
* @param {string} string Text string to shorten
* @param {number} maxLength The max length of the string
* @param {boolean} wrapEvents Wrap the text after the line has reached maxLength
* @param {number} maxTitleLines The max number of vertical lines before cutting event title
* @returns {string} The shortened string
*/
shorten (string, maxLength, wrapEvents, maxTitleLines) {
if (typeof string !== "string") {
return "";
}
if (wrapEvents === true) {
const words = string.split(" ");
let temp = "";
let currentLine = "";
let line = 0;
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) {
// max - 1 to account for a space
currentLine += `${word} `;
} else {
line++;
if (line > maxTitleLines - 1) {
if (i < words.length) {
currentLine += "…";
}
break;
}
if (currentLine.length > 0) {
temp += `${currentLine}<br>${word} `;
} else {
temp += `${word}<br>`;
}
currentLine = "";
}
}
return (temp + currentLine).trim();
} else {
if (maxLength && typeof maxLength === "number" && string.length > maxLength) {
return `${string.trim().slice(0, maxLength)}`;
} else {
return string.trim();
}
}
},
/**
* Transforms the title of an event for usage.
* Replaces parts of the text as defined in config.titleReplace.
* @param {string} title The title to transform.
* @param {object} titleReplace object definition of parts to be replaced in the title
* object definition:
* search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calculation, the element matching the year must be in a RegEx group
* replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation)
* yearmatchgroup: {number,optional} match group for year element
* @returns {string} The transformed title.
*/
titleTransform (title, titleReplace) {
let transformedTitle = title;
for (let tr in titleReplace) {
let transform = titleReplace[tr];
if (typeof transform === "object") {
if (typeof transform.search !== "undefined" && transform.search !== "" && typeof transform.replace !== "undefined") {
let regParts = transform.search.match(/^\/(.+)\/([gim]*)$/);
let needle = new RegExp(transform.search, "g");
if (regParts) {
// the parsed pattern is a regexp with flags.
needle = new RegExp(regParts[1], regParts[2]);
}
let replacement = transform.replace;
if (typeof transform.yearmatchgroup !== "undefined" && transform.yearmatchgroup !== "") {
const yearmatch = [...title.matchAll(needle)];
if (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) {
let calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1;
let searchstr = `$${transform.yearmatchgroup}`;
replacement = replacement.replace(searchstr, calcage);
}
}
transformedTitle = transformedTitle.replace(needle, replacement);
}
}
}
return transformedTitle;
}
};
if (typeof module !== "undefined") {
module.exports = CalendarUtils;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* CalendarFetcher Tester
* use this script with `node debug.js` to test the fetcher without the need
* of starting the MagicMirror² core. Adjust the values below to your desire.
*/
// Load internal alias resolver
require("../../js/alias-resolver");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL
//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first)
const fetchInterval = 60 * 60 * 1000;
const maximumEntries = 10;
const maximumNumberOfDays = 365;
const user = "magicmirror";
const pass = "MyStrongPass";
const auth = {
user: user,
pass: pass
};
Log.log("Create fetcher ...");
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
fetcher.onReceive(function (fetcher) {
Log.log(fetcher.events);
process.exit(0);
});
fetcher.onError(function (fetcher, error) {
Log.log("Fetcher error:", error);
process.exit(1);
});
fetcher.startFetch();
Log.log("Create fetcher done! ");
+101
View File
@@ -0,0 +1,101 @@
const zlib = require("node:zlib");
const NodeHelper = require("node_helper");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
module.exports = NodeHelper.create({
// Override start method.
start () {
Log.log(`Starting node helper for: ${this.name}`);
this.fetchers = [];
},
// Override socketNotificationReceived method.
socketNotificationReceived (notification, payload) {
if (notification === "ADD_CALENDAR") {
this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id);
} else if (notification === "FETCH_CALENDAR") {
const key = payload.id + payload.url;
if (typeof this.fetchers[key] === "undefined") {
Log.error("No fetcher exists with key: ", key);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
return;
}
this.fetchers[key].fetchCalendar();
}
},
/**
* Creates a fetcher for a new url if it doesn't exist yet.
* Otherwise it reuses the existing one.
* @param {string} url The url of the calendar
* @param {number} fetchInterval How often does the calendar needs to be fetched in ms
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
* @param {number} maximumEntries The maximum number of events fetched.
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
* @param {object} auth The object containing options for authentication against the calendar.
* @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
* @param {string} identifier ID of the module
*/
createFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {
try {
new URL(url);
} catch (error) {
Log.error("Malformed calendar url: ", url, error);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
let fetcher;
let fetchIntervalCorrected;
if (typeof this.fetchers[identifier + url] === "undefined") {
if (fetchInterval < 60000) {
Log.warn(`fetchInterval for url ${url} must be >= 60000`);
fetchIntervalCorrected = 60000;
}
Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`);
fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);
fetcher.onReceive((fetcher) => {
this.broadcastEvents(fetcher, identifier);
});
fetcher.onError((fetcher, errorInfo) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, errorInfo.message || errorInfo);
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type: errorInfo.translationKey
});
});
this.fetchers[identifier + url] = fetcher;
fetcher.fetchCalendar();
} else {
Log.log(`Use existing calendarfetcher for url: ${url}`);
fetcher = this.fetchers[identifier + url];
// Check if calendar data is stale and needs refresh
if (fetcher.shouldRefetch()) {
Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`);
fetcher.fetchCalendar();
} else {
fetcher.broadcastEvents();
}
}
},
/**
*
* @param {object} fetcher the fetcher associated with the calendar
* @param {string} identifier the identifier of the calendar
*/
broadcastEvents (fetcher, identifier) {
const checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), "utf8"));
this.sendSocketNotification("CALENDAR_EVENTS", {
id: identifier,
url: fetcher.url,
events: fetcher.events,
checksum: checksum
});
}
});
+237
View File
@@ -0,0 +1,237 @@
{
"Dateline Standard Time": { "iana": ["Etc/GMT+12"] },
"UTC-11": { "iana": ["Etc/GMT+11"] },
"Aleutian Standard Time": { "iana": ["America/Adak"] },
"Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] },
"Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] },
"Alaskan Standard Time": { "iana": ["America/Anchorage"] },
"UTC-09": { "iana": ["Etc/GMT+9"] },
"Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] },
"UTC-08": { "iana": ["Etc/GMT+8"] },
"Pacific Standard Time": { "iana": ["America/Los_Angeles"] },
"US Mountain Standard Time": { "iana": ["America/Phoenix"] },
"Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] },
"Mountain Standard Time": { "iana": ["America/Denver"] },
"Central America Standard Time": { "iana": ["America/Guatemala"] },
"Central Standard Time": { "iana": ["America/Chicago"] },
"Easter Island Standard Time": { "iana": ["Pacific/Easter"] },
"Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] },
"Canada Central Standard Time": { "iana": ["America/Regina"] },
"SA Pacific Standard Time": { "iana": ["America/Bogota"] },
"Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] },
"Eastern Standard Time": { "iana": ["America/New_York"] },
"Haiti Standard Time": { "iana": ["America/Port-au-Prince"] },
"Cuba Standard Time": { "iana": ["America/Havana"] },
"US Eastern Standard Time": { "iana": ["America/Indianapolis"] },
"Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] },
"Paraguay Standard Time": { "iana": ["America/Asuncion"] },
"Atlantic Standard Time": { "iana": ["America/Halifax"] },
"Venezuela Standard Time": { "iana": ["America/Caracas"] },
"Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] },
"SA Western Standard Time": { "iana": ["America/La_Paz"] },
"Pacific SA Standard Time": { "iana": ["America/Santiago"] },
"Newfoundland Standard Time": { "iana": ["America/St_Johns"] },
"Tocantins Standard Time": { "iana": ["America/Araguaina"] },
"E. South America Standard Time": { "iana": ["America/Sao_Paulo"] },
"SA Eastern Standard Time": { "iana": ["America/Cayenne"] },
"Argentina Standard Time": { "iana": ["America/Buenos_Aires"] },
"Greenland Standard Time": { "iana": ["America/Godthab"] },
"Montevideo Standard Time": { "iana": ["America/Montevideo"] },
"Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] },
"Saint Pierre Standard Time": { "iana": ["America/Miquelon"] },
"Bahia Standard Time": { "iana": ["America/Bahia"] },
"UTC-02": { "iana": ["Etc/GMT+2"] },
"Azores Standard Time": { "iana": ["Atlantic/Azores"] },
"Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] },
"UTC": { "iana": ["Etc/GMT"] },
"GMT Standard Time": { "iana": ["Europe/London"] },
"Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] },
"Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] },
"Morocco Standard Time": { "iana": ["Africa/Casablanca"] },
"W. Europe Standard Time": { "iana": ["Europe/Berlin"] },
"Central Europe Standard Time": { "iana": ["Europe/Budapest"] },
"Romance Standard Time": { "iana": ["Europe/Paris"] },
"Central European Standard Time": { "iana": ["Europe/Warsaw"] },
"W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] },
"Jordan Standard Time": { "iana": ["Asia/Amman"] },
"GTB Standard Time": { "iana": ["Europe/Bucharest"] },
"Middle East Standard Time": { "iana": ["Asia/Beirut"] },
"Egypt Standard Time": { "iana": ["Africa/Cairo"] },
"E. Europe Standard Time": { "iana": ["Europe/Chisinau"] },
"Syria Standard Time": { "iana": ["Asia/Damascus"] },
"West Bank Standard Time": { "iana": ["Asia/Hebron"] },
"South Africa Standard Time": { "iana": ["Africa/Johannesburg"] },
"FLE Standard Time": { "iana": ["Europe/Kiev"] },
"Israel Standard Time": { "iana": ["Asia/Jerusalem"] },
"Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] },
"Sudan Standard Time": { "iana": ["Africa/Khartoum"] },
"Libya Standard Time": { "iana": ["Africa/Tripoli"] },
"Namibia Standard Time": { "iana": ["Africa/Windhoek"] },
"Arabic Standard Time": { "iana": ["Asia/Baghdad"] },
"Turkey Standard Time": { "iana": ["Europe/Istanbul"] },
"Arab Standard Time": { "iana": ["Asia/Riyadh"] },
"Belarus Standard Time": { "iana": ["Europe/Minsk"] },
"Russian Standard Time": { "iana": ["Europe/Moscow"] },
"E. Africa Standard Time": { "iana": ["Africa/Nairobi"] },
"Iran Standard Time": { "iana": ["Asia/Tehran"] },
"Arabian Standard Time": { "iana": ["Asia/Dubai"] },
"Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] },
"Azerbaijan Standard Time": { "iana": ["Asia/Baku"] },
"Russia Time Zone 3": { "iana": ["Europe/Samara"] },
"Mauritius Standard Time": { "iana": ["Indian/Mauritius"] },
"Saratov Standard Time": { "iana": ["Europe/Saratov"] },
"Georgian Standard Time": { "iana": ["Asia/Tbilisi"] },
"Volgograd Standard Time": { "iana": ["Europe/Volgograd"] },
"Caucasus Standard Time": { "iana": ["Asia/Yerevan"] },
"Afghanistan Standard Time": { "iana": ["Asia/Kabul"] },
"West Asia Standard Time": { "iana": ["Asia/Tashkent"] },
"Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] },
"Pakistan Standard Time": { "iana": ["Asia/Karachi"] },
"Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] },
"India Standard Time": { "iana": ["Asia/Calcutta"] },
"Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] },
"Nepal Standard Time": { "iana": ["Asia/Katmandu"] },
"Central Asia Standard Time": { "iana": ["Asia/Almaty"] },
"Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] },
"Omsk Standard Time": { "iana": ["Asia/Omsk"] },
"Myanmar Standard Time": { "iana": ["Asia/Rangoon"] },
"SE Asia Standard Time": { "iana": ["Asia/Bangkok"] },
"Altai Standard Time": { "iana": ["Asia/Barnaul"] },
"W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] },
"North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] },
"N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] },
"Tomsk Standard Time": { "iana": ["Asia/Tomsk"] },
"China Standard Time": { "iana": ["Asia/Shanghai"] },
"North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] },
"Singapore Standard Time": { "iana": ["Asia/Singapore"] },
"W. Australia Standard Time": { "iana": ["Australia/Perth"] },
"Taipei Standard Time": { "iana": ["Asia/Taipei"] },
"Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] },
"Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] },
"Transbaikal Standard Time": { "iana": ["Asia/Chita"] },
"Tokyo Standard Time": { "iana": ["Asia/Tokyo"] },
"North Korea Standard Time": { "iana": ["Asia/Pyongyang"] },
"Korea Standard Time": { "iana": ["Asia/Seoul"] },
"Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] },
"Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] },
"AUS Central Standard Time": { "iana": ["Australia/Darwin"] },
"E. Australia Standard Time": { "iana": ["Australia/Brisbane"] },
"AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] },
"West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] },
"Tasmania Standard Time": { "iana": ["Australia/Hobart"] },
"Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] },
"Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] },
"Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] },
"Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] },
"Magadan Standard Time": { "iana": ["Asia/Magadan"] },
"Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] },
"Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] },
"Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] },
"Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] },
"New Zealand Standard Time": { "iana": ["Pacific/Auckland"] },
"UTC+12": { "iana": ["Etc/GMT-12"] },
"Fiji Standard Time": { "iana": ["Pacific/Fiji"] },
"Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] },
"UTC+13": { "iana": ["Etc/GMT-13"] },
"Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] },
"Samoa Standard Time": { "iana": ["Pacific/Apia"] },
"Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] },
"(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] },
"(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] },
"(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] },
"(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] },
"(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
"(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
"(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] },
"(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] },
"(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] },
"(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] },
"(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] },
"(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] },
"(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] },
"(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] },
"(UTC-06:00) Central America": { "iana": ["America/Guatemala"] },
"(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] },
"(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] },
"(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] },
"(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] },
"(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] },
"(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] },
"(UTC-04:00) Santiago": { "iana": ["America/Santiago"] },
"(UTC-03:30) Newfoundland": { "iana": [null] },
"(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] },
"(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] },
"(UTC-03:00) Greenland": { "iana": ["America/Godthab"] },
"(UTC-02:00) Mid-Atlantic": { "iana": [null] },
"(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] },
"(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] },
"(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] },
"(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] },
"(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] },
"(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] },
"(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] },
"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] },
"(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] },
"(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] },
"(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] },
"(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] },
"(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] },
"(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] },
"(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] },
"(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] },
"(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] },
"(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] },
"(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] },
"(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] },
"(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] },
"(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] },
"(UTC+04:30) Kabul": { "iana": [null] },
"(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] },
"(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] },
"(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] },
"(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] },
"(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] },
"(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] },
"(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] },
"(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] },
"(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] },
"(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] },
"(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] },
"(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] },
"(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] },
"(UTC+08:00) Perth": { "iana": ["Australia/Perth"] },
"(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] },
"(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] },
"(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] },
"(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] },
"(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] },
"(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] },
"(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
"(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
"(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] },
"(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] },
"(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] },
"(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] },
"(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] },
"(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] },
"(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] },
"(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] },
"(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] },
"(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] },
"(UTC+02:00) Amman": { "iana": ["Asia/Amman"] },
"(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] },
"(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] },
"(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] },
"(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] },
"(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] },
"(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] },
"(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] },
"(UTC+04:00) Yerevan": { "iana": [null] },
"(UTC-04:30) Caracas": { "iana": ["America/Caracas"] },
"(UTC) Casablanca": { "iana": ["Africa/Casablanca"] },
"(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] },
"(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] },
"(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] },
"(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] },
"(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] }
}