/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * Portions Copyright (C) Philipp Kewisch */ import Time from "./time.js"; import Component from "./component.js"; import ICALParse from "./parse.js"; import { clone, binsearchInsert } from "./helpers.js"; const OPTIONS = ["tzid", "location", "tznames", "latitude", "longitude"]; /** * Timezone representation. * * @example * var vcalendar; * var timezoneComp = vcalendar.getFirstSubcomponent('vtimezone'); * var tzid = timezoneComp.getFirstPropertyValue('tzid'); * * var timezone = new ICAL.Timezone({ * component: timezoneComp, * tzid * }); * * @memberof ICAL */ class Timezone { static _compare_change_fn(a, b) { if (a.year < b.year) return -1; else if (a.year > b.year) return 1; if (a.month < b.month) return -1; else if (a.month > b.month) return 1; if (a.day < b.day) return -1; else if (a.day > b.day) return 1; if (a.hour < b.hour) return -1; else if (a.hour > b.hour) return 1; if (a.minute < b.minute) return -1; else if (a.minute > b.minute) return 1; if (a.second < b.second) return -1; else if (a.second > b.second) return 1; return 0; } /** * Convert the date/time from one zone to the next. * * @param {Time} tt The time to convert * @param {Timezone} from_zone The source zone to convert from * @param {Timezone} to_zone The target zone to convert to * @return {Time} The converted date/time object */ static convert_time(tt, from_zone, to_zone) { if (tt.isDate || from_zone.tzid == to_zone.tzid || from_zone == Timezone.localTimezone || to_zone == Timezone.localTimezone) { tt.zone = to_zone; return tt; } let utcOffset = from_zone.utcOffset(tt); tt.adjust(0, 0, 0, - utcOffset); utcOffset = to_zone.utcOffset(tt); tt.adjust(0, 0, 0, utcOffset); return null; } /** * Creates a new ICAL.Timezone instance from the passed data object. * * @param {Component|Object} aData options for class * @param {String|Component} aData.component * If aData is a simple object, then this member can be set to either a * string containing the component data, or an already parsed * ICAL.Component * @param {String} aData.tzid The timezone identifier * @param {String} aData.location The timezone locationw * @param {String} aData.tznames An alternative string representation of the * timezone * @param {Number} aData.latitude The latitude of the timezone * @param {Number} aData.longitude The longitude of the timezone */ static fromData(aData) { let tt = new Timezone(); return tt.fromData(aData); } /** * The instance describing the UTC timezone * @type {Timezone} * @constant * @instance */ static #utcTimezone = null; static get utcTimezone() { if (!this.#utcTimezone) { this.#utcTimezone = Timezone.fromData({ tzid: "UTC" }); } return this.#utcTimezone; } /** * The instance describing the local timezone * @type {Timezone} * @constant * @instance */ static #localTimezone = null; static get localTimezone() { if (!this.#localTimezone) { this.#localTimezone = Timezone.fromData({ tzid: "floating" }); } return this.#localTimezone; } /** * Adjust a timezone change object. * @private * @param {Object} change The timezone change object * @param {Number} days The extra amount of days * @param {Number} hours The extra amount of hours * @param {Number} minutes The extra amount of minutes * @param {Number} seconds The extra amount of seconds */ static adjust_change(change, days, hours, minutes, seconds) { return Time.prototype.adjust.call( change, days, hours, minutes, seconds, change ); } static _minimumExpansionYear = -1; static EXTRA_COVERAGE = 5; /** * Creates a new ICAL.Timezone instance, by passing in a tzid and component. * * @param {Component|Object} data options for class * @param {String|Component} data.component * If data is a simple object, then this member can be set to either a * string containing the component data, or an already parsed * ICAL.Component * @param {String} data.tzid The timezone identifier * @param {String} data.location The timezone locationw * @param {String} data.tznames An alternative string representation of the * timezone * @param {Number} data.latitude The latitude of the timezone * @param {Number} data.longitude The longitude of the timezone */ constructor(data) { this.wrappedJSObject = this; this.fromData(data); } /** * Timezone identifier * @type {String} */ tzid = ""; /** * Timezone location * @type {String} */ location = ""; /** * Alternative timezone name, for the string representation * @type {String} */ tznames = ""; /** * The primary latitude for the timezone. * @type {Number} */ latitude = 0.0; /** * The primary longitude for the timezone. * @type {Number} */ longitude = 0.0; /** * The vtimezone component for this timezone. * @type {Component} */ component = null; /** * The year this timezone has been expanded to. All timezone transition * dates until this year are known and can be used for calculation * * @private * @type {Number} */ expandedUntilYear = 0; /** * The class identifier. * @constant * @type {String} * @default "icaltimezone" */ icalclass = "icaltimezone"; /** * Sets up the current instance using members from the passed data object. * * @param {Component|Object} aData options for class * @param {String|Component} aData.component * If aData is a simple object, then this member can be set to either a * string containing the component data, or an already parsed * ICAL.Component * @param {String} aData.tzid The timezone identifier * @param {String} aData.location The timezone locationw * @param {String} aData.tznames An alternative string representation of the * timezone * @param {Number} aData.latitude The latitude of the timezone * @param {Number} aData.longitude The longitude of the timezone */ fromData(aData) { this.expandedUntilYear = 0; this.changes = []; if (aData instanceof Component) { // Either a component is passed directly this.component = aData; } else { // Otherwise the component may be in the data object if (aData && "component" in aData) { if (typeof aData.component == "string") { // If a string was passed, parse it as a component let jCal = ICALParse(aData.component); this.component = new Component(jCal); } else if (aData.component instanceof Component) { // If it was a component already, then just set it this.component = aData.component; } else { // Otherwise just null out the component this.component = null; } } // Copy remaining passed properties for (let prop of OPTIONS) { if (aData && prop in aData) { this[prop] = aData[prop]; } } } // If we have a component but no TZID, attempt to get it from the // component's properties. if (this.component instanceof Component && !this.tzid) { this.tzid = this.component.getFirstPropertyValue('tzid'); } return this; } /** * Finds the utcOffset the given time would occur in this timezone. * * @param {Time} tt The time to check for * @return {Number} utc offset in seconds */ utcOffset(tt) { if (this == Timezone.utcTimezone || this == Timezone.localTimezone) { return 0; } this._ensureCoverage(tt.year); if (!this.changes.length) { return 0; } let tt_change = { year: tt.year, month: tt.month, day: tt.day, hour: tt.hour, minute: tt.minute, second: tt.second }; let change_num = this._findNearbyChange(tt_change); let change_num_to_use = -1; let step = 1; // TODO: replace with bin search? for (;;) { let change = clone(this.changes[change_num], true); if (change.utcOffset < change.prevUtcOffset) { Timezone.adjust_change(change, 0, 0, 0, change.utcOffset); } else { Timezone.adjust_change(change, 0, 0, 0, change.prevUtcOffset); } let cmp = Timezone._compare_change_fn(tt_change, change); if (cmp >= 0) { change_num_to_use = change_num; } else { step = -1; } if (step == -1 && change_num_to_use != -1) { break; } change_num += step; if (change_num < 0) { return 0; } if (change_num >= this.changes.length) { break; } } let zone_change = this.changes[change_num_to_use]; let utcOffset_change = zone_change.utcOffset - zone_change.prevUtcOffset; if (utcOffset_change < 0 && change_num_to_use > 0) { let tmp_change = clone(zone_change, true); Timezone.adjust_change(tmp_change, 0, 0, 0, tmp_change.prevUtcOffset); if (Timezone._compare_change_fn(tt_change, tmp_change) < 0) { let prev_zone_change = this.changes[change_num_to_use - 1]; let want_daylight = false; // TODO if (zone_change.is_daylight != want_daylight && prev_zone_change.is_daylight == want_daylight) { zone_change = prev_zone_change; } } } // TODO return is_daylight? return zone_change.utcOffset; } _findNearbyChange(change) { // find the closest match let idx = binsearchInsert( this.changes, change, Timezone._compare_change_fn ); if (idx >= this.changes.length) { return this.changes.length - 1; } return idx; } _ensureCoverage(aYear) { if (Timezone._minimumExpansionYear == -1) { let today = Time.now(); Timezone._minimumExpansionYear = today.year; } let changesEndYear = aYear; if (changesEndYear < Timezone._minimumExpansionYear) { changesEndYear = Timezone._minimumExpansionYear; } changesEndYear += Timezone.EXTRA_COVERAGE; if (!this.changes.length || this.expandedUntilYear < aYear) { let subcomps = this.component.getAllSubcomponents(); let compLen = subcomps.length; let compIdx = 0; for (; compIdx < compLen; compIdx++) { this._expandComponent( subcomps[compIdx], changesEndYear, this.changes ); } this.changes.sort(Timezone._compare_change_fn); this.expandedUntilYear = changesEndYear; } } _expandComponent(aComponent, aYear, changes) { if (!aComponent.hasProperty("dtstart") || !aComponent.hasProperty("tzoffsetto") || !aComponent.hasProperty("tzoffsetfrom")) { return null; } let dtstart = aComponent.getFirstProperty("dtstart").getFirstValue(); let change; function convert_tzoffset(offset) { return offset.factor * (offset.hours * 3600 + offset.minutes * 60); } function init_changes() { let changebase = {}; changebase.is_daylight = (aComponent.name == "daylight"); changebase.utcOffset = convert_tzoffset( aComponent.getFirstProperty("tzoffsetto").getFirstValue() ); changebase.prevUtcOffset = convert_tzoffset( aComponent.getFirstProperty("tzoffsetfrom").getFirstValue() ); return changebase; } if (!aComponent.hasProperty("rrule") && !aComponent.hasProperty("rdate")) { change = init_changes(); change.year = dtstart.year; change.month = dtstart.month; change.day = dtstart.day; change.hour = dtstart.hour; change.minute = dtstart.minute; change.second = dtstart.second; Timezone.adjust_change(change, 0, 0, 0, -change.prevUtcOffset); changes.push(change); } else { let props = aComponent.getAllProperties("rdate"); for (let rdate of props) { let time = rdate.getFirstValue(); change = init_changes(); change.year = time.year; change.month = time.month; change.day = time.day; if (time.isDate) { change.hour = dtstart.hour; change.minute = dtstart.minute; change.second = dtstart.second; if (dtstart.zone != Timezone.utcTimezone) { Timezone.adjust_change(change, 0, 0, 0, -change.prevUtcOffset); } } else { change.hour = time.hour; change.minute = time.minute; change.second = time.second; if (time.zone != Timezone.utcTimezone) { Timezone.adjust_change(change, 0, 0, 0, -change.prevUtcOffset); } } changes.push(change); } let rrule = aComponent.getFirstProperty("rrule"); if (rrule) { rrule = rrule.getFirstValue(); change = init_changes(); if (rrule.until && rrule.until.zone == Timezone.utcTimezone) { rrule.until.adjust(0, 0, 0, change.prevUtcOffset); rrule.until.zone = Timezone.localTimezone; } let iterator = rrule.iterator(dtstart); let occ; while ((occ = iterator.next())) { change = init_changes(); if (occ.year > aYear || !occ) { break; } change.year = occ.year; change.month = occ.month; change.day = occ.day; change.hour = occ.hour; change.minute = occ.minute; change.second = occ.second; change.isDate = occ.isDate; Timezone.adjust_change(change, 0, 0, 0, -change.prevUtcOffset); changes.push(change); } } } return changes; } /** * The string representation of this timezone. * @return {String} */ toString() { return (this.tznames ? this.tznames : this.tzid); } } export default Timezone;