// Package icalfix normalizes non-standard timezone identifiers in raw // iCalendar data before it's handed to go-ical's decoder. // // go-ical resolves a DTSTART/DTEND's TZID parameter (and, for recurring // events, the RRULE expansion's start time) via a direct // time.LoadLocation(tzid) call — see // github.com/emersion/go-ical@.../ical.go's Prop.DateTime and // components.go's Component.RecurrenceSet. That only understands IANA // zone names ("Europe/Berlin"), but Windows/Outlook (and anything built // on the Windows timezone database, e.g. Exchange, Thunderbird's Lightning // on some setups) commonly emits Windows zone names instead // ("W. Europe Standard Time"), which time.LoadLocation doesn't recognize. // A recurring event stored with such a TZID decodes fine (go-ical doesn't // eagerly parse RRULE dates), but fails later — as a 500 — the moment a // CalDAV client issues a time-range REPORT that has to expand its // recurrence, since that's when RecurrenceSet actually calls // time.LoadLocation. package icalfix import "regexp" // tzidParamRe matches a "TZID=" (optionally quoted) parameter value // as it appears attached to any property (e.g. "DTSTART;TZID=W. Europe // Standard Time:..."). The name runs until the next ':' or ';' delimiter // or line break, so it captures the parameter's full value even though it // contains spaces and periods. var tzidParamRe = regexp.MustCompile(`TZID=("?)([^";:\r\n]+)("?)`) // tzidLineRe matches a standalone "TZID:" property line, as found // inside a VTIMEZONE component. The trailing "(\r?)" capture group is // required, not cosmetic: Go's RE2 engine only matches "$" in multi-line // mode immediately before a literal "\n", not before "\r\n" — so without // consuming (and re-emitting) an optional trailing "\r" explicitly, this // regex silently fails to match on any CRLF-terminated feed (which is // every Outlook/Exchange-published ICS feed in practice), leaving the // VTIMEZONE's own TZID line un-normalized even though the "TZID=..." // parameter form (tzidParamRe, used on DTSTART/DTEND/etc.) still matches // fine. The practical effect was a VTIMEZONE whose declared TZID // ("W. Europe Standard Time") never matched the now-normalized // "TZID=Europe/Berlin" parameters referencing it elsewhere in the same // object, so any code trying to find "the VTIMEZONE for Europe/Berlin" // (see internal/icssub.Series.referencedTimezones) would never find one. var tzidLineRe = regexp.MustCompile(`(?m)^TZID:([^\r\n]+)(\r?)$`) // NormalizeTimeZones rewrites any recognized Windows timezone identifier // in data to its IANA equivalent, leaving everything else (including // unrecognized TZIDs) untouched. It's safe to call on data that has no // Windows TZIDs at all — such data passes through unchanged. func NormalizeTimeZones(data []byte) []byte { data = tzidParamRe.ReplaceAllFunc(data, func(m []byte) []byte { sub := tzidParamRe.FindSubmatch(m) name := string(sub[2]) iana, ok := windowsToIANA[name] if !ok { return m } return []byte("TZID=" + string(sub[1]) + iana + string(sub[3])) }) data = tzidLineRe.ReplaceAllFunc(data, func(m []byte) []byte { sub := tzidLineRe.FindSubmatch(m) name := string(sub[1]) iana, ok := windowsToIANA[name] if !ok { return m } // sub[2] is the optional trailing "\r" the regex consumed as part // of the match (see the doc comment above) — it must be re-emitted // here, or a CRLF-terminated line loses its "\r" and every // subsequent line in the file ends up misaligned relative to the // original byte offsets a caller might have recorded. return []byte("TZID:" + iana + string(sub[2])) }) return data } // windowsToIANA maps common Windows timezone identifiers (as found in the // CLDR windowsZones.xml "001"/default territory mapping) to an IANA zone // name recognized by Go's tzdata. It's not exhaustive, but covers the // zones seen in practice from Outlook/Exchange-generated ICS data. var windowsToIANA = map[string]string{ "Dateline Standard Time": "Etc/GMT+12", "UTC-11": "Etc/GMT+11", "Aleutian Standard Time": "America/Adak", "Hawaiian Standard Time": "Pacific/Honolulu", "Marquesas Standard Time": "Pacific/Marquesas", "Alaskan Standard Time": "America/Anchorage", "UTC-09": "Etc/GMT+9", "Pacific Standard Time (Mexico)": "America/Tijuana", "UTC-08": "Etc/GMT+8", "Pacific Standard Time": "America/Los_Angeles", "US Mountain Standard Time": "America/Phoenix", "Mountain Standard Time (Mexico)": "America/Chihuahua", "Mountain Standard Time": "America/Denver", "Central America Standard Time": "America/Guatemala", "Central Standard Time": "America/Chicago", "Central Standard Time (Mexico)": "America/Mexico_City", "Canada Central Standard Time": "America/Regina", "SA Pacific Standard Time": "America/Bogota", "Eastern Standard Time (Mexico)": "America/Cancun", "Eastern Standard Time": "America/New_York", "Haiti Standard Time": "America/Port-au-Prince", "Cuba Standard Time": "America/Havana", "US Eastern Standard Time": "America/Indianapolis", "Turks And Caicos Standard Time": "America/Grand_Turk", "Paraguay Standard Time": "America/Asuncion", "Atlantic Standard Time": "America/Halifax", "Venezuela Standard Time": "America/Caracas", "Central Brazilian Standard Time": "America/Cuiaba", "SA Western Standard Time": "America/La_Paz", "Pacific SA Standard Time": "America/Santiago", "Newfoundland Standard Time": "America/St_Johns", "Tocantins Standard Time": "America/Araguaina", "E. South America Standard Time": "America/Sao_Paulo", "SA Eastern Standard Time": "America/Cayenne", "Argentina Standard Time": "America/Buenos_Aires", "Greenland Standard Time": "America/Godthab", "Montevideo Standard Time": "America/Montevideo", "Magallanes Standard Time": "America/Punta_Arenas", "Saint Pierre Standard Time": "America/Miquelon", "Bahia Standard Time": "America/Bahia", "UTC-02": "Etc/GMT+2", "Azores Standard Time": "Atlantic/Azores", "Cape Verde Standard Time": "Atlantic/Cape_Verde", "UTC": "Etc/UTC", "GMT Standard Time": "Europe/London", "Greenwich Standard Time": "Atlantic/Reykjavik", "Sao Tome Standard Time": "Africa/Sao_Tome", "Morocco Standard Time": "Africa/Casablanca", "W. Europe Standard Time": "Europe/Berlin", "Central Europe Standard Time": "Europe/Budapest", "Romance Standard Time": "Europe/Paris", "Central European Standard Time": "Europe/Warsaw", "W. Central Africa Standard Time": "Africa/Lagos", "Jordan Standard Time": "Asia/Amman", "GTB Standard Time": "Europe/Bucharest", "Middle East Standard Time": "Asia/Beirut", "Egypt Standard Time": "Africa/Cairo", "E. Europe Standard Time": "Europe/Chisinau", "Syria Standard Time": "Asia/Damascus", "West Bank Standard Time": "Asia/Hebron", "South Africa Standard Time": "Africa/Johannesburg", "FLE Standard Time": "Europe/Kiev", "Israel Standard Time": "Asia/Jerusalem", "Kaliningrad Standard Time": "Europe/Kaliningrad", "Sudan Standard Time": "Africa/Khartoum", "Libya Standard Time": "Africa/Tripoli", "Namibia Standard Time": "Africa/Windhoek", "Arabic Standard Time": "Asia/Baghdad", "Turkey Standard Time": "Europe/Istanbul", "Arab Standard Time": "Asia/Riyadh", "Belarus Standard Time": "Europe/Minsk", "Russian Standard Time": "Europe/Moscow", "E. Africa Standard Time": "Africa/Nairobi", "Volgograd Standard Time": "Europe/Volgograd", "Iran Standard Time": "Asia/Tehran", "Arabian Standard Time": "Asia/Dubai", "Astrakhan Standard Time": "Europe/Astrakhan", "Azerbaijan Standard Time": "Asia/Baku", "Russia Time Zone 3": "Europe/Samara", "Mauritius Standard Time": "Indian/Mauritius", "Saratov Standard Time": "Europe/Saratov", "Georgian Standard Time": "Asia/Tbilisi", "Caucasus Standard Time": "Asia/Yerevan", "Afghanistan Standard Time": "Asia/Kabul", "West Asia Standard Time": "Asia/Tashkent", "Ekaterinburg Standard Time": "Asia/Yekaterinburg", "Pakistan Standard Time": "Asia/Karachi", "Qyzylorda Standard Time": "Asia/Qyzylorda", "India Standard Time": "Asia/Calcutta", "Sri Lanka Standard Time": "Asia/Colombo", "Nepal Standard Time": "Asia/Katmandu", "Central Asia Standard Time": "Asia/Almaty", "Bangladesh Standard Time": "Asia/Dhaka", "Omsk Standard Time": "Asia/Omsk", "Myanmar Standard Time": "Asia/Rangoon", "SE Asia Standard Time": "Asia/Bangkok", "Altai Standard Time": "Asia/Barnaul", "W. Mongolia Standard Time": "Asia/Hovd", "North Asia Standard Time": "Asia/Krasnoyarsk", "N. Central Asia Standard Time": "Asia/Novosibirsk", "Tomsk Standard Time": "Asia/Tomsk", "China Standard Time": "Asia/Shanghai", "North Asia East Standard Time": "Asia/Irkutsk", "Singapore Standard Time": "Asia/Singapore", "W. Australia Standard Time": "Australia/Perth", "Taipei Standard Time": "Asia/Taipei", "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", "Aus Central W. Standard Time": "Australia/Eucla", "Transbaikal Standard Time": "Asia/Chita", "Tokyo Standard Time": "Asia/Tokyo", "North Korea Standard Time": "Asia/Pyongyang", "Korea Standard Time": "Asia/Seoul", "Yakutsk Standard Time": "Asia/Yakutsk", "Cen. Australia Standard Time": "Australia/Adelaide", "AUS Central Standard Time": "Australia/Darwin", "E. Australia Standard Time": "Australia/Brisbane", "AUS Eastern Standard Time": "Australia/Sydney", "West Pacific Standard Time": "Pacific/Port_Moresby", "Tasmania Standard Time": "Australia/Hobart", "Vladivostok Standard Time": "Asia/Vladivostok", "Lord Howe Standard Time": "Australia/Lord_Howe", "Bougainville Standard Time": "Pacific/Bougainville", "Russia Time Zone 10": "Asia/Srednekolymsk", "Magadan Standard Time": "Asia/Magadan", "Norfolk Standard Time": "Pacific/Norfolk", "Sakhalin Standard Time": "Asia/Sakhalin", "Central Pacific Standard Time": "Pacific/Guadalcanal", "Russia Time Zone 11": "Asia/Kamchatka", "New Zealand Standard Time": "Pacific/Auckland", "UTC+12": "Etc/GMT-12", "Fiji Standard Time": "Pacific/Fiji", "Kamchatka Standard Time": "Asia/Kamchatka", "Chatham Islands Standard Time": "Pacific/Chatham", "UTC+13": "Etc/GMT-13", "Tonga Standard Time": "Pacific/Tongatapu", "Samoa Standard Time": "Pacific/Apia", "Line Islands Standard Time": "Pacific/Kiritimati", }