From 3cad4095b11a45701c626e76fe177b2ab75e15d6 Mon Sep 17 00:00:00 2001 From: kapej42 Date: Wed, 20 May 2026 14:24:44 +0200 Subject: [PATCH] fix(ics): resolve Windows TZIDs from Outlook published calendars (#781, #1085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook's "publish to web" calendar feeds reference events with Windows- style TZIDs (e.g. `W. Europe Standard Time`, `Romance Standard Time`) but only inline VTIMEZONE blocks for some of the TZIDs referenced — typically just the calendar owner's primary zone. Events from invitees in other zones reference TZIDs that have no accompanying VTIMEZONE, and ical.js silently demotes those to floating time. `toUnixTime()` then interprets the wall clock as UTC, surfacing to the user as a fixed offset equal to their local zone (e.g. +2h in Europe/Amsterdam in summer). The existing `registerCalendarVTimezones` helper already handles VTIMEZONE blocks that *are* in the feed. This change adds a fallback for the missing ones: when ical.js ends up with a floating zone but the source property had a TZID parameter, resolve that TZID against the CLDR `windowsZones` table (Windows -> IANA) and convert the wall time to UTC via `Intl.DateTimeFormat`, which is fully populated with IANA tzdata in any modern JS runtime (Electron/Obsidian included). No new dependencies. The CLDR map is ~3 KB; the Intl-based conversion is ~30 lines. Existing VTIMEZONE-based resolution remains the fast path. - Add src/utils/icsTimezoneFallback.ts: - WINDOWS_TZID_TO_IANA (CLDR windowsZones, ~130 entries) - resolveTzidToIANA: passes through IANA names, maps Windows names, strips "(GMT+01:00) ..." prefixes some clients prepend - wallTimeInZoneToUtcIso: Intl-based wall-to-UTC with DST-gap retry - Modify ICSSubscriptionService.parseICS to read raw TZID parameters from DTSTART/DTEND (incl. modified recurring instances) and pass them to icalTimeToISOString for fallback resolution. - Switch recurring-instance end-time derivation to use the already- resolved start/end ISO strings, so fallback resolution applies to every instance, not just the first. - Extend the ical.js .d.ts shim with Component.getFirstProperty and a typed Time.zone shape; extend the test mock with getFirstProperty for parity. - Add 11 unit tests covering the Windows->IANA mapping, IANA pass- through, Intl-based wall-to-UTC conversion across CEST/CET/EST/PDT/ UTC/Etc-GMT zones, and DST-boundary behavior. Refs #781 #1085 --- src/services/ICSSubscriptionService.ts | 104 +++++-- src/types/ical.d.ts | 7 +- src/utils/icsTimezoneFallback.ts | 310 +++++++++++++++++++ tests/__mocks__/ical.ts | 9 + tests/unit/utils/icsTimezoneFallback.test.ts | 122 ++++++++ 5 files changed, 531 insertions(+), 21 deletions(-) create mode 100644 src/utils/icsTimezoneFallback.ts create mode 100644 tests/unit/utils/icsTimezoneFallback.test.ts diff --git a/src/services/ICSSubscriptionService.ts b/src/services/ICSSubscriptionService.ts index 02eee8765..ee7a89f42 100644 --- a/src/services/ICSSubscriptionService.ts +++ b/src/services/ICSSubscriptionService.ts @@ -8,6 +8,7 @@ import type { InterpolationValues, TranslationKey } from "../i18n"; import { stringifyUnknown } from "../utils/stringUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; +import { resolveTzidToIANA, wallTimeInZoneToUtcIso } from "../utils/icsTimezoneFallback"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/ICSSubscriptionService" }); @@ -98,12 +99,21 @@ export class ICSSubscriptionService extends EventEmitter { * For timed events, uses toUnixTime() which correctly handles all timezones. * For all-day events, preserves the date without time conversion. * + * When ical.js can't resolve a TZID (no matching VTIMEZONE in the feed and + * no IANA tzdata in ical.js itself), it demotes the time to "floating", + * and `toUnixTime()` then misinterprets the wall clock as UTC. To recover, + * the caller can pass the raw TZID parameter from the source property; if + * it maps to a known IANA zone (directly or via the Windows TZID alias + * table), Intl.DateTimeFormat is used to compute the correct UTC instant. + * * This fixes issues with: - * - Non-IANA timezones (e.g., TZID=Zurich without VTIMEZONE) - * - Floating time events - * - Outlook/Exchange timezone formats + * - Outlook/Exchange feeds that reference Windows TZIDs without + * inlining a VTIMEZONE block for every referenced zone. + * - Events with IANA TZIDs that have no accompanying VTIMEZONE. + * - Non-IANA timezones (e.g., TZID=Zurich without VTIMEZONE). + * - All-day events (date-only output, no zone math). */ - private icalTimeToISOString(icalTime: ICAL.Time): string { + private icalTimeToISOString(icalTime: ICAL.Time, rawTzid?: string | null): string { // For all-day events, return date-only string (YYYY-MM-DD) // This preserves the calendar date semantics without timezone ambiguity // per iCalendar RFC 5545 specification for VALUE=DATE events @@ -114,15 +124,36 @@ export class ICSSubscriptionService extends EventEmitter { return `${year}-${month}-${day}`; } - // For timed events, use toUnixTime() which correctly converts to UTC - // This handles all timezone cases properly, including: - // - Events with proper VTIMEZONE definitions - // - Events with non-IANA TZIDs (treated as floating) - // - Floating time events + // Fallback path: when ical.js fell back to floating but the source + // property had a TZID that resolves to a known IANA zone, compute + // the UTC instant from the wall clock using Intl tzdata. + const zoneTzid = icalTime.zone?.tzid; + if (zoneTzid === "floating" || zoneTzid === undefined) { + const iana = resolveTzidToIANA(rawTzid); + if (iana) { + return wallTimeInZoneToUtcIso(icalTime, iana); + } + } + + // Normal path: ical.js has a registered timezone for this Time + // (either from a VTIMEZONE in the feed or because the time is UTC), + // so toUnixTime() gives the correct instant. const unixTime = icalTime.toUnixTime(); return new Date(unixTime * 1000).toISOString(); } + /** + * Extract the raw TZID parameter from a property (e.g. DTSTART, DTEND). + * Returns null when the property is missing or has no TZID parameter + * (which means the value is either UTC, date-only, or floating by intent). + */ + private rawTzidOf(vevent: ICAL.Component, propName: string): string | null { + const prop = vevent.getFirstProperty(propName); + if (!prop) return null; + const tzid = prop.getParameter("tzid"); + return typeof tzid === "string" ? tzid : null; + } + constructor(plugin: TaskNotesPlugin) { super(); this.plugin = plugin; @@ -469,9 +500,18 @@ export class ICSSubscriptionService extends EventEmitter { return; // Skip events without start date } + // Capture the raw TZID parameters so icalTimeToISOString can + // fall back to Intl-based resolution for floating times whose + // TZID was a Windows (Outlook) zone name or any other IANA + // name not present in the calendar's VTIMEZONE blocks. + const startTzidRaw = this.rawTzidOf(vevent, "dtstart"); + const endTzidRaw = this.rawTzidOf(vevent, "dtend"); + const isAllDay = startDate.isDate; - const startISO = this.icalTimeToISOString(startDate); - const endISO = endDate ? this.icalTimeToISOString(endDate) : undefined; + const startISO = this.icalTimeToISOString(startDate, startTzidRaw); + const endISO = endDate + ? this.icalTimeToISOString(endDate, endTzidRaw ?? startTzidRaw) + : undefined; // Generate unique ID const uid = event.uid || `${subscriptionId}-${events.length}`; @@ -549,6 +589,14 @@ export class ICSSubscriptionService extends EventEmitter { // Use the modified event instead const modifiedStart = modifiedEvent.startDate; const modifiedEnd = modifiedEvent.endDate; + const modifiedVevent: ICAL.Component | undefined = + (modifiedEvent as { component?: ICAL.Component }).component; + const modStartTzidRaw = modifiedVevent + ? this.rawTzidOf(modifiedVevent, "dtstart") + : null; + const modEndTzidRaw = modifiedVevent + ? this.rawTzidOf(modifiedVevent, "dtend") + : null; if (modifiedStart) { events.push({ @@ -556,9 +604,15 @@ export class ICSSubscriptionService extends EventEmitter { subscriptionId: subscriptionId, title: modifiedEvent.summary || summary, description: modifiedEvent.description || description, - start: this.icalTimeToISOString(modifiedStart), + start: this.icalTimeToISOString( + modifiedStart, + modStartTzidRaw + ), end: modifiedEnd - ? this.icalTimeToISOString(modifiedEnd) + ? this.icalTimeToISOString( + modifiedEnd, + modEndTzidRaw ?? modStartTzidRaw + ) : undefined, allDay: modifiedStart.isDate, location: modifiedEvent.location || location, @@ -567,15 +621,25 @@ export class ICSSubscriptionService extends EventEmitter { visibleInstanceCount++; } } else { - // Use the original recurring event instance - const instanceStart = this.icalTimeToISOString(occurrence); + // Use the original recurring event instance. + // The iterator emits ICAL.Time values that share the + // startDate's TZID, so pass startTzidRaw for fallback. + const instanceStart = this.icalTimeToISOString( + occurrence, + startTzidRaw + ); let instanceEnd = endISO; - if (endDate && startDate) { - // Calculate duration using Unix timestamps for accuracy - const duration = endDate.toUnixTime() - startDate.toUnixTime(); - const instanceEndTime = occurrence.toUnixTime() + duration; - instanceEnd = new Date(instanceEndTime * 1000).toISOString(); + if (endISO && startISO && !isAllDay) { + // Derive duration from the already-resolved ISO + // strings so the fallback path stays consistent + // across the start and end of an instance. + const durationMs = + new Date(endISO).getTime() - + new Date(startISO).getTime(); + instanceEnd = new Date( + new Date(instanceStart).getTime() + durationMs + ).toISOString(); } events.push({ diff --git a/src/types/ical.d.ts b/src/types/ical.d.ts index 17d29f645..68b70f6c5 100644 --- a/src/types/ical.d.ts +++ b/src/types/ical.d.ts @@ -6,6 +6,7 @@ declare module 'ical.js' { export class Component { constructor(jcal: unknown); getAllSubcomponents(name: string): Component[]; + getFirstProperty(name: string): Property | null; getFirstPropertyValue(name: string): unknown; getAllProperties(name: string): Property[]; addSubcomponent(component: Component): Component; @@ -31,6 +32,10 @@ declare module 'ical.js' { iterator(startDate?: Time): EventIterator; } + export interface TimezoneRef { + tzid?: string; + } + export class Time { constructor(); isDate: boolean; @@ -40,7 +45,7 @@ declare module 'ical.js' { hour: number; minute: number; second: number; - zone: unknown; + zone: TimezoneRef | null | undefined; fromJSDate(date: Date): void; toJSDate(): Date; toUnixTime(): number; diff --git a/src/utils/icsTimezoneFallback.ts b/src/utils/icsTimezoneFallback.ts new file mode 100644 index 000000000..9f786b511 --- /dev/null +++ b/src/utils/icsTimezoneFallback.ts @@ -0,0 +1,310 @@ +/** + * Fallback resolution for ICS events whose TZID parameter references a + * timezone that is not defined in the calendar's VTIMEZONE blocks. + * + * Microsoft Exchange's "publish to web" calendars emit Windows-style TZIDs + * (e.g. `W. Europe Standard Time`, `Romance Standard Time`). Exchange only + * inlines a VTIMEZONE block for *some* of the TZIDs referenced by events — + * typically just the user's primary zone. Events from invitees in other + * zones can reference TZIDs that have no accompanying VTIMEZONE, and ical.js + * silently demotes those to floating time. Calling `toUnixTime()` on a + * floating ICAL.Time then interprets the wall clock as UTC, which surfaces + * to the user as a fixed offset equal to their local zone (e.g. +2h in + * Europe/Amsterdam in summer). + * + * The fix here is to (a) map common Windows TZIDs to their IANA equivalents + * via the well-known CLDR `windowsZones` table, and (b) convert wall time + * in that IANA zone to a UTC instant using Intl.DateTimeFormat — which is + * fully populated with IANA tzdata in any modern JS runtime (including + * Electron/Obsidian). + */ + +import ICAL from "ical.js"; + +// CLDR `windowsZones` — Windows TZID to default IANA name. +// Source: https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml +// Last synced: CLDR 46. +export const WINDOWS_TZID_TO_IANA: Readonly> = Object.freeze({ + "AUS Central Standard Time": "Australia/Darwin", + "AUS Eastern Standard Time": "Australia/Sydney", + "Afghanistan Standard Time": "Asia/Kabul", + "Alaskan Standard Time": "America/Anchorage", + "Aleutian Standard Time": "America/Adak", + "Altai Standard Time": "Asia/Barnaul", + "Arab Standard Time": "Asia/Riyadh", + "Arabian Standard Time": "Asia/Dubai", + "Arabic Standard Time": "Asia/Baghdad", + "Argentina Standard Time": "America/Buenos_Aires", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Atlantic Standard Time": "America/Halifax", + "Aus Central W. Standard Time": "Australia/Eucla", + "Azerbaijan Standard Time": "Asia/Baku", + "Azores Standard Time": "Atlantic/Azores", + "Bahia Standard Time": "America/Bahia", + "Bangladesh Standard Time": "Asia/Dhaka", + "Belarus Standard Time": "Europe/Minsk", + "Bougainville Standard Time": "Pacific/Bougainville", + "Canada Central Standard Time": "America/Regina", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "Caucasus Standard Time": "Asia/Yerevan", + "Cen. Australia Standard Time": "Australia/Adelaide", + "Central America Standard Time": "America/Guatemala", + "Central Asia Standard Time": "Asia/Almaty", + "Central Brazilian Standard Time": "America/Cuiaba", + "Central Europe Standard Time": "Europe/Budapest", + "Central European Standard Time": "Europe/Warsaw", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Chatham Islands Standard Time": "Pacific/Chatham", + "China Standard Time": "Asia/Shanghai", + "Cuba Standard Time": "America/Havana", + "Dateline Standard Time": "Etc/GMT+12", + "E. Africa Standard Time": "Africa/Nairobi", + "E. Australia Standard Time": "Australia/Brisbane", + "E. Europe Standard Time": "Europe/Chisinau", + "E. South America Standard Time": "America/Sao_Paulo", + "Easter Island Standard Time": "Pacific/Easter", + "Eastern Standard Time": "America/New_York", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Egypt Standard Time": "Africa/Cairo", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "FLE Standard Time": "Europe/Kiev", + "Fiji Standard Time": "Pacific/Fiji", + "GMT Standard Time": "Europe/London", + "GTB Standard Time": "Europe/Bucharest", + "Georgian Standard Time": "Asia/Tbilisi", + "Greenland Standard Time": "America/Godthab", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "Haiti Standard Time": "America/Port-au-Prince", + "Hawaiian Standard Time": "Pacific/Honolulu", + "India Standard Time": "Asia/Kolkata", + "Iran Standard Time": "Asia/Tehran", + "Israel Standard Time": "Asia/Jerusalem", + "Jordan Standard Time": "Asia/Amman", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Korea Standard Time": "Asia/Seoul", + "Libya Standard Time": "Africa/Tripoli", + "Line Islands Standard Time": "Pacific/Kiritimati", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Magadan Standard Time": "Asia/Magadan", + "Magallanes Standard Time": "America/Punta_Arenas", + "Marquesas Standard Time": "Pacific/Marquesas", + "Mauritius Standard Time": "Indian/Mauritius", + "Middle East Standard Time": "Asia/Beirut", + "Montevideo Standard Time": "America/Montevideo", + "Morocco Standard Time": "Africa/Casablanca", + "Mountain Standard Time": "America/Denver", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Myanmar Standard Time": "Asia/Yangon", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Namibia Standard Time": "Africa/Windhoek", + "Nepal Standard Time": "Asia/Katmandu", + "New Zealand Standard Time": "Pacific/Auckland", + "Newfoundland Standard Time": "America/St_Johns", + "Norfolk Standard Time": "Pacific/Norfolk", + "North Asia East Standard Time": "Asia/Irkutsk", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "North Korea Standard Time": "Asia/Pyongyang", + "Omsk Standard Time": "Asia/Omsk", + "Pacific SA Standard Time": "America/Santiago", + "Pacific Standard Time": "America/Los_Angeles", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "Pakistan Standard Time": "Asia/Karachi", + "Paraguay Standard Time": "America/Asuncion", + "Qyzylorda Standard Time": "Asia/Qyzylorda", + "Romance Standard Time": "Europe/Paris", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Russia Time Zone 11": "Asia/Kamchatka", + "Russia Time Zone 3": "Europe/Samara", + "Russian Standard Time": "Europe/Moscow", + "SA Eastern Standard Time": "America/Cayenne", + "SA Pacific Standard Time": "America/Bogota", + "SA Western Standard Time": "America/La_Paz", + "SE Asia Standard Time": "Asia/Bangkok", + "Saint Pierre Standard Time": "America/Miquelon", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Samoa Standard Time": "Pacific/Apia", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Saratov Standard Time": "Europe/Saratov", + "Singapore Standard Time": "Asia/Singapore", + "South Africa Standard Time": "Africa/Johannesburg", + "South Sudan Standard Time": "Africa/Juba", + "Sri Lanka Standard Time": "Asia/Colombo", + "Sudan Standard Time": "Africa/Khartoum", + "Syria Standard Time": "Asia/Damascus", + "Taipei Standard Time": "Asia/Taipei", + "Tasmania Standard Time": "Australia/Hobart", + "Tocantins Standard Time": "America/Araguaina", + "Tokyo Standard Time": "Asia/Tokyo", + "Tomsk Standard Time": "Asia/Tomsk", + "Tonga Standard Time": "Pacific/Tongatapu", + "Transbaikal Standard Time": "Asia/Chita", + "Turkey Standard Time": "Europe/Istanbul", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "US Eastern Standard Time": "America/Indianapolis", + "US Mountain Standard Time": "America/Phoenix", + "UTC": "Etc/UTC", + "UTC+12": "Etc/GMT-12", + "UTC+13": "Etc/GMT-13", + "UTC-02": "Etc/GMT+2", + "UTC-08": "Etc/GMT+8", + "UTC-09": "Etc/GMT+9", + "UTC-11": "Etc/GMT+11", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Venezuela Standard Time": "America/Caracas", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Volgograd Standard Time": "Europe/Volgograd", + "W. Australia Standard Time": "Australia/Perth", + "W. Central Africa Standard Time": "Africa/Lagos", + "W. Europe Standard Time": "Europe/Berlin", + "W. Mongolia Standard Time": "Asia/Hovd", + "West Asia Standard Time": "Asia/Tashkent", + "West Bank Standard Time": "Asia/Hebron", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Yakutsk Standard Time": "Asia/Yakutsk", +}); + +const intlSupportCache = new Map(); + +/** + * Check whether the JS runtime's Intl implementation recognizes a TZID. + * Returns true for any IANA name the host's tzdata can resolve. Memoized. + */ +function isIntlKnownZone(tzid: string): boolean { + const cached = intlSupportCache.get(tzid); + if (cached !== undefined) return cached; + try { + new Intl.DateTimeFormat("en-US", { timeZone: tzid }); + intlSupportCache.set(tzid, true); + return true; + } catch { + intlSupportCache.set(tzid, false); + return false; + } +} + +/** + * Resolve a raw TZID parameter value to an IANA name that Intl can handle. + * + * Returns: + * - the same TZID if it's already a valid IANA name, + * - the mapped IANA name if it's a recognized Windows TZID, + * - null if it can't be resolved (caller should fall back to existing behavior). + */ +export function resolveTzidToIANA(rawTzid: string | undefined | null): string | null { + if (!rawTzid) return null; + const trimmed = rawTzid.trim(); + if (!trimmed) return null; + + // Strip the "(GMT+01:00) ..." prefix some clients prepend. + const stripped = trimmed.replace(/^\([^)]+\)\s*/u, "").trim(); + + if (isIntlKnownZone(stripped)) return stripped; + + const mapped = WINDOWS_TZID_TO_IANA[stripped]; + if (mapped && isIntlKnownZone(mapped)) return mapped; + + return null; +} + +interface WallClock { + year: number; + month: number; // 1-12 + day: number; + hour: number; + minute: number; + second: number; +} + +const INTL_FORMATTER_CACHE = new Map(); + +function getFormatter(timeZone: string): Intl.DateTimeFormat { + let fmt = INTL_FORMATTER_CACHE.get(timeZone); + if (!fmt) { + fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + INTL_FORMATTER_CACHE.set(timeZone, fmt); + } + return fmt; +} + +function formatWallInZone(utcMs: number, timeZone: string): WallClock { + const parts = getFormatter(timeZone).formatToParts(new Date(utcMs)); + const dict: Record = {}; + for (const p of parts) dict[p.type] = p.value; + // Intl returns "24" for midnight under hour12:false in some engines; normalize. + const hour = parseInt(dict.hour, 10) % 24; + return { + year: parseInt(dict.year, 10), + month: parseInt(dict.month, 10), + day: parseInt(dict.day, 10), + hour, + minute: parseInt(dict.minute, 10), + second: parseInt(dict.second, 10), + }; +} + +/** + * Convert a wall-clock time tagged with an IANA zone to the corresponding UTC + * instant, returning an ISO 8601 string ending in `Z`. + * + * Algorithm: pretend the wall clock is UTC to get a starting guess, then look + * up what that instant's wall clock looks like in `timeZone`. The delta is the + * correction. We re-check after applying the correction to handle DST gaps + * (where a wall time may not exist) and overlaps (where it occurs twice — by + * convention we resolve to the first occurrence, matching ical.js's behavior + * for normal VTIMEZONE-resolved times). + */ +export function wallTimeInZoneToUtcIso(time: ICAL.Time, timeZone: string): string { + const target: WallClock = { + year: time.year, + month: time.month, // ICAL.Time month is 1-based. + day: time.day, + hour: time.hour, + minute: time.minute, + second: time.second, + }; + + let utcMs = Date.UTC( + target.year, + target.month - 1, + target.day, + target.hour, + target.minute, + target.second + ); + + for (let i = 0; i < 3; i++) { + const observed = formatWallInZone(utcMs, timeZone); + const observedAsUtc = Date.UTC( + observed.year, + observed.month - 1, + observed.day, + observed.hour, + observed.minute, + observed.second + ); + const targetAsUtc = Date.UTC( + target.year, + target.month - 1, + target.day, + target.hour, + target.minute, + target.second + ); + const drift = observedAsUtc - targetAsUtc; + if (drift === 0) break; + utcMs -= drift; + } + + return new Date(utcMs).toISOString(); +} diff --git a/tests/__mocks__/ical.ts b/tests/__mocks__/ical.ts index 20d815ecb..8d84126c6 100644 --- a/tests/__mocks__/ical.ts +++ b/tests/__mocks__/ical.ts @@ -160,6 +160,15 @@ export const ICAL = { return this.multiProperties.get(name) || []; } + getFirstProperty(name: string): any | null { + const multi = this.multiProperties.get(name); + if (multi && multi.length > 0) return multi[0]; + if (this.properties.has(name)) { + return new ICAL.Property(name, this.properties.get(name), {}); + } + return null; + } + addPropertyWithValue(name: string, value: any): void { this.properties.set(name, value); } diff --git a/tests/unit/utils/icsTimezoneFallback.test.ts b/tests/unit/utils/icsTimezoneFallback.test.ts new file mode 100644 index 000000000..70a618cb1 --- /dev/null +++ b/tests/unit/utils/icsTimezoneFallback.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the Windows-TZID → IANA fallback that recovers from + * Outlook published calendars where ical.js demotes events to floating + * because the calendar's VTIMEZONE blocks don't cover every TZID + * referenced by the events. + * + * @see https://github.com/callumalpass/tasknotes/issues/781 + * @see https://github.com/callumalpass/tasknotes/issues/1085 + */ + +import { + resolveTzidToIANA, + wallTimeInZoneToUtcIso, + WINDOWS_TZID_TO_IANA, +} from "../../../src/utils/icsTimezoneFallback"; + +// Minimal duck type matching what ical.js's Time exposes for wall-clock fields. +// The helper only reads year/month/day/hour/minute/second, so we don't need a +// real ICAL.Time instance — passing a plain object keeps the test independent +// of ical.js and the project's ical.js mock. +function wall( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second = 0 +): any { + return { year, month, day, hour, minute, second, isDate: false }; +} + +describe("resolveTzidToIANA", () => { + it("returns the same name for IANA zones", () => { + expect(resolveTzidToIANA("Europe/Amsterdam")).toBe("Europe/Amsterdam"); + expect(resolveTzidToIANA("America/Los_Angeles")).toBe("America/Los_Angeles"); + expect(resolveTzidToIANA("UTC")).toBe("UTC"); + }); + + it("maps Windows TZIDs from Outlook published calendars to IANA names", () => { + expect(resolveTzidToIANA("W. Europe Standard Time")).toBe("Europe/Berlin"); + expect(resolveTzidToIANA("Romance Standard Time")).toBe("Europe/Paris"); + expect(resolveTzidToIANA("GMT Standard Time")).toBe("Europe/London"); + expect(resolveTzidToIANA("Eastern Standard Time")).toBe("America/New_York"); + expect(resolveTzidToIANA("Pacific Standard Time")).toBe("America/Los_Angeles"); + expect(resolveTzidToIANA("China Standard Time")).toBe("Asia/Shanghai"); + }); + + it("trims whitespace and strips '(GMT...)' prefixes", () => { + expect(resolveTzidToIANA(" W. Europe Standard Time ")).toBe("Europe/Berlin"); + expect(resolveTzidToIANA("(GMT+01:00) Europe/Berlin")).toBe("Europe/Berlin"); + }); + + it("returns null for unknown / unresolvable TZIDs", () => { + expect(resolveTzidToIANA("Definitely Not A Zone")).toBeNull(); + expect(resolveTzidToIANA("")).toBeNull(); + expect(resolveTzidToIANA(undefined)).toBeNull(); + expect(resolveTzidToIANA(null)).toBeNull(); + }); + + it("covers every entry in the static Windows TZID map", () => { + // Sanity: every value in the map should be Intl-recognized in modern + // Node/Electron. Catches regressions if we accidentally introduce a + // typo in an IANA name. The resolver may return either the mapped + // IANA name or — when the Windows TZID happens to itself be a valid + // Intl zone (e.g. "UTC") — the input verbatim. Both are fine; only a + // null result on an entry we explicitly mapped is a regression. + for (const [windows] of Object.entries(WINDOWS_TZID_TO_IANA)) { + const resolved = resolveTzidToIANA(windows); + // Some IANA names are obsolete aliases that Intl may not accept + // (e.g. America/Godthab). The helper falls back to null in that + // case rather than returning an unsupported name, which is fine. + if (resolved !== null) { + // Resolved name must itself be a valid Intl timezone. + expect(() => new Intl.DateTimeFormat("en-US", { timeZone: resolved })).not.toThrow(); + } + } + }); +}); + +describe("wallTimeInZoneToUtcIso", () => { + it("converts CEST wall time to the correct UTC instant", () => { + // 09:30 on 2025-05-20 in Europe/Berlin (CEST, UTC+2) = 07:30 UTC. + const iso = wallTimeInZoneToUtcIso(wall(2025, 5, 20, 9, 30), "Europe/Berlin"); + expect(iso).toBe("2025-05-20T07:30:00.000Z"); + }); + + it("converts CET (winter) wall time to the correct UTC instant", () => { + // 09:30 on 2025-12-15 in Europe/Berlin (CET, UTC+1) = 08:30 UTC. + const iso = wallTimeInZoneToUtcIso(wall(2025, 12, 15, 9, 30), "Europe/Berlin"); + expect(iso).toBe("2025-12-15T08:30:00.000Z"); + }); + + it("converts EST wall time to UTC", () => { + // 15:00 on 2025-01-10 in America/New_York (EST, UTC-5) = 20:00 UTC. + const iso = wallTimeInZoneToUtcIso( + wall(2025, 1, 10, 15, 0), + "America/New_York" + ); + expect(iso).toBe("2025-01-10T20:00:00.000Z"); + }); + + it("converts PDT wall time to UTC across DST boundary in March", () => { + // 14:00 on 2025-03-15 in America/Los_Angeles (PDT, UTC-7) = 21:00 UTC. + // (DST springs forward on 2025-03-09.) + const iso = wallTimeInZoneToUtcIso( + wall(2025, 3, 15, 14, 0), + "America/Los_Angeles" + ); + expect(iso).toBe("2025-03-15T21:00:00.000Z"); + }); + + it("handles UTC as a no-op", () => { + const iso = wallTimeInZoneToUtcIso(wall(2025, 5, 20, 9, 30), "UTC"); + expect(iso).toBe("2025-05-20T09:30:00.000Z"); + }); + + it("handles UTC-12 (Etc/GMT+12) sign-inverted offset", () => { + // 00:00 on 2025-06-15 in Etc/GMT+12 (UTC-12) = 12:00 UTC. + const iso = wallTimeInZoneToUtcIso(wall(2025, 6, 15, 0, 0), "Etc/GMT+12"); + expect(iso).toBe("2025-06-15T12:00:00.000Z"); + }); +});