diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 75985c328..406fedc1c 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -43,9 +43,9 @@ "@octokit/auth-app": "^8.2.0", "ical.js": "^2.2.1", "maria2": "^0.4.1", - "node-ical": "^0.23.1", "octokit": "^5.0.5", "proxmox-api": "1.1.1", + "timezones-ical-library": "^2.1.3", "tsdav": "^2.1.8", "undici": "7.24.6", "xml2js": "^0.6.2", diff --git a/packages/integrations/src/nextcloud/nextcloud.integration.ts b/packages/integrations/src/nextcloud/nextcloud.integration.ts index 5c8aabc90..ff41d8300 100644 --- a/packages/integrations/src/nextcloud/nextcloud.integration.ts +++ b/packages/integrations/src/nextcloud/nextcloud.integration.ts @@ -2,8 +2,9 @@ import type { Agent } from "https"; import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; +import ICAL from "ical.js"; import type { RequestInit as NodeFetchRequestInit } from "node-fetch"; -import * as ical from "node-ical"; +import { tzlib_get_ical_block, tzlib_get_timezones } from "timezones-ical-library"; import { DAVClient } from "tsdav"; import { z } from "zod"; @@ -21,6 +22,20 @@ import type { CalendarEvent } from "../interfaces/calendar/calendar-types"; import type { Notification } from "../interfaces/notifications/notification-types"; import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration"; +// Register all existing timezones +if (ICAL.TimezoneService.count === 0) { + const timezones = tzlib_get_timezones() as string[]; + for (const tzid of timezones) { + ICAL.TimezoneService.register( + new ICAL.Timezone({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion + component: new ICAL.Component(ICAL.parse((tzlib_get_ical_block(tzid) as string[])[0]!)), + tzid, + }), + ); + } +} + const notificationSchema = z.object({ ocs: z.object({ data: z.array( @@ -68,39 +83,64 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr return calendarEvents .map((event) => { - // @ts-expect-error the typescript definitions for this package are wrong - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - const icalData = ical.default.parseICS(event.data) as ical.CalendarResponse; - const veventObject = Object.values(icalData).find((data) => data.type === "VEVENT"); - - if (!veventObject) { - throw new Error(`Invalid event data object: ${JSON.stringify(event.data)}. Unable to process the calendar.`); + if (typeof event.data !== "string") { + return []; } + const comp = ICAL.Component.fromString(event.data); + const veventComponent = comp.getFirstSubcomponent("vevent"); + + if (!veventComponent) { + return []; + } + + const veventObject = new ICAL.Event(veventComponent); logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`); const eventUrlWithoutHost = new URL(event.url).pathname; const eventSlug = Buffer.from(eventUrlWithoutHost).toString("base64url"); - const startDates = veventObject.rrule ? veventObject.rrule.between(start, end) : [veventObject.start]; + const durationMs = veventObject.duration.toSeconds() * 1000; - const durationMs = veventObject.end.getTime() - veventObject.start.getTime(); + // Handle recurring events + let startDates: Date[]; + if (veventObject.isRecurring()) { + const iterator = veventObject.iterator(); + startDates = []; + + // Iterate through occurrences within the time range + let next: ICAL.Time | undefined; + // next actually returns undefined when there are no more occurrences + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while ((next = iterator.next())) { + const nextStartDate = next.convertToZone(ICAL.Timezone.utcTimezone).toJSDate(); + if (nextStartDate > end) break; + const nextEndDate = new Date(nextStartDate.getTime() + durationMs); + if (nextEndDate < start) continue; + + startDates.push(nextStartDate); + } + } else { + startDates = [veventObject.startDate.convertToZone(ICAL.Timezone.utcTimezone).toJSDate()]; + } return startDates.map((startDate) => { - const utcStartDate = dayjs(startDate).tz("UTC").toDate(); - const endDate = new Date(utcStartDate.getTime() + durationMs); - const dateInMillis = utcStartDate.valueOf(); + const endDate = new Date(startDate.getTime() + durationMs); + const dateInMillis = startDate.valueOf(); + + // Get color property from the component + const colorProperty = veventComponent.getFirstProperty("color"); + const color = colorProperty?.getFirstValue() as string | undefined; return { title: veventObject.summary, subTitle: null, - description: veventObject.description, - startDate: utcStartDate, + description: veventObject.description || null, + startDate, endDate, image: null, location: veventObject.location || null, - indicatorColor: - "color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600", + indicatorColor: color ?? "#ff8600", links: [ { href: this.externalUrl( diff --git a/packages/integrations/test/nextcloud.spec.ts b/packages/integrations/test/nextcloud.spec.ts new file mode 100644 index 000000000..cef3ae494 --- /dev/null +++ b/packages/integrations/test/nextcloud.spec.ts @@ -0,0 +1,454 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { StartedTestContainer } from "testcontainers"; +import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { createId } from "@homarr/common"; +import { createDb } from "@homarr/db/test"; + +import { NextcloudIntegration } from "../src"; + +// Mock the database +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + +// Mock certificate verification +vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => { + return Promise.resolve([]); + }), + }; +}); + +const IMAGE_NAME = "nextcloud:latest"; +const DEFAULT_USERNAME = "admin"; +const DEFAULT_PASSWORD = "admin123"; + +interface CalendarTestEvent { + summary: string; + description?: string; + location?: string; + color?: string; + dtstart: string; + dtend: string; + rrule?: string; + timezone?: string; +} + +describe("Nextcloud integration", () => { + let startedContainer: StartedTestContainer | null = null; + let nextcloudIntegration: NextcloudIntegration | null = null; + + beforeAll(async () => { + const containerRuntimeClient = await getContainerRuntimeClient(); + await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME)); + + startedContainer = await prepareNextcloudContainerAsync(); + nextcloudIntegration = createNextcloudIntegration(startedContainer); + }, 100_000); + + afterAll(async () => { + if (startedContainer) { + await startedContainer.stop(); + startedContainer = null; + } + }); + + describe("Test connection", () => { + test("Test connection > Should work with valid credentials", async () => { + // Act + const result = await nextcloudIntegration!.testConnectionAsync(); + + // Assert + expect(result.success).toBe(true); + }, 20_000); + + test("Test connection > Should fail with invalid credentials", async () => { + // Arrange + const invalidIntegration = createNextcloudIntegration(startedContainer!, "wrong-user", "wrong-pass"); + + // Act + const result = await invalidIntegration.testConnectionAsync(); + + // Assert + expect(result.success).toBe(false); + }, 20_000); + }); + + describe("Retrieve calendar events", () => { + test("Retrieve calendar events > Should retrieve all fields correctly", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Test Meeting", + description: "This is a test meeting", + location: "Conference Room A", + color: "#FF5733", + dtstart: "20260415T140000Z", + dtend: "20260415T150000Z", + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + const retrievedEvent = relevantEvents[0]; + expect(retrievedEvent).toMatchObject({ + title: testEvent.summary, + description: testEvent.description, + location: testEvent.location, + indicatorColor: testEvent.color, + }); + expect(retrievedEvent!.startDate.toISOString()).toBe("2026-04-15T14:00:00.000Z"); + expect(retrievedEvent!.endDate?.toISOString()).toBe("2026-04-15T15:00:00.000Z"); + }, 20_000); + test("Retrieve calendar events > Should work with only required fields", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Simple Event", + dtstart: "20260415T140000Z", + dtend: "20260415T150000Z", + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + const retrievedEvent = relevantEvents[0]; + expect(retrievedEvent?.location).toBeNull(); + expect(retrievedEvent?.description).toBeNull(); + expect(retrievedEvent?.indicatorColor).toBe("#ff8600"); // Default color + }, 20_000); + test("Retrieve calendar events > Should work with timezone specified", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Timezone Event", + dtstart: "20260415T140000", + dtend: "20260415T150000", + timezone: "Asia/Tokyo", + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + const retrievedEvent = relevantEvents[0]; + + expect(retrievedEvent!.startDate.toISOString()).toBe("2026-04-15T05:00:00.000Z"); // 14:00 in Tokyo is 05:00 UTC + expect(retrievedEvent!.endDate?.toISOString()).toBe("2026-04-15T06:00:00.000Z"); // 15:00 in Tokyo is 06:00 UTC + }, 120_000); + test("Retrieve calendar events > Should work with event that starts before the range but ends within the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Early Meeting", + dtstart: "20260401T230000Z", // Starts before the range + dtend: "20260402T010000Z", // Ends within the range + }; + const start = new Date("2026-04-02T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + }, 20_000); + test("Retrieve calendar events > Should work with event that starts within the range but ends after the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Late Meeting", + dtstart: "20260430T230000Z", // Starts within the range + dtend: "20260501T010000Z", // Ends after the range + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + }, 20_000); + test("Retrieve calendar events > Should not retrieve events outside the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Out of Range Meeting", + dtstart: "20260301T140000Z", // Starts before the range + dtend: "20260301T150000Z", // Ends before the range + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(0); + }, 20_000); + test("Retrieve calendar events > Should work with events that begin before the range and end after the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Spanning Meeting", + dtstart: "20260330T230000Z", // Starts before the range + dtend: "20260501T010000Z", // Ends after the range + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(1); + }, 20_000); + test("Retrieve calendar events > Should work with recurring events first occurrence before the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Recurring Meeting", + dtstart: "20260330T140000Z", // First occurrence starts before the range + dtend: "20260330T150000Z", + rrule: "FREQ=DAILY;COUNT=5", // Daily for 5 occurrences + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(3); + }, 20_000); + test("Retrieve calendar events > Should work with recurring events that go beyond the range", async () => { + // Arrange + const calendarName = createId(); + const testEvent: CalendarTestEvent = { + summary: "Working Week", + dtstart: "20260302T090000Z", + dtend: "20260306T170000Z", + rrule: "FREQ=WEEKLY;COUNT=10", + }; + const start = new Date("2026-04-01T00:00:00Z"); + const end = new Date("2026-04-30T23:59:59Z"); + + await createCalendarAsync(startedContainer!, calendarName); + await createCalendarEventAsync(startedContainer!, calendarName, testEvent); + + // Act + const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end); + + // Assert + const relevantEvents = events.filter((event) => event.title === testEvent.summary); + expect(relevantEvents).toHaveLength(5); + }); + }); +}); + +/** + * Prepares a Nextcloud container with proper configuration + */ +async function prepareNextcloudContainerAsync(): Promise { + const container = createNextcloudContainer(); + const startedContainer = await container.start(); + + return startedContainer; +} + +/** + * Creates a Nextcloud container with proper configuration + */ +function createNextcloudContainer() { + return new GenericContainer(IMAGE_NAME) + .withEnvironment({ + NEXTCLOUD_ADMIN_USER: DEFAULT_USERNAME, + NEXTCLOUD_ADMIN_PASSWORD: DEFAULT_PASSWORD, + NEXTCLOUD_TRUSTED_DOMAINS: "*", + SQLITE_DATABASE: "nextcloud", // Use SQLite for faster startup + }) + .withExposedPorts(80) + .withWaitStrategy(Wait.forHttp("/status.php", 80).forStatusCode(200)) + .withStartupTimeout(120_000); +} + +/** + * Creates a Nextcloud integration instance + */ +function createNextcloudIntegration( + container: StartedTestContainer, + username?: string, + password?: string, +): NextcloudIntegration { + return new NextcloudIntegration({ + id: "1", + decryptedSecrets: [ + { + kind: "username", + value: username ?? DEFAULT_USERNAME, + }, + { + kind: "password", + value: password ?? DEFAULT_PASSWORD, + }, + ], + name: "Nextcloud", + url: `http://${container.getHost()}:${container.getMappedPort(80)}`, + externalUrl: null, + }); +} + +/** + * Creates a calendar in Nextcloud via OCS API + */ +async function createCalendarAsync(container: StartedTestContainer, calendarName: string): Promise { + const host = container.getHost(); + const port = container.getMappedPort(80); + const url = `http://${host}:${port}/remote.php/dav/calendars/${DEFAULT_USERNAME}/${calendarName}`; + + const auth = Buffer.from(`${DEFAULT_USERNAME}:${DEFAULT_PASSWORD}`).toString("base64"); + + const response = await fetch(url, { + method: "MKCALENDAR", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/xml", + }, + body: ` + + + + ${calendarName} + + + `, + }); + + if (!response.ok && response.status !== 201) { + throw new Error(`Failed to create calendar: ${response.status} ${response.statusText}`); + } +} + +/** + * Creates a calendar event in Nextcloud via CalDAV + */ +async function createCalendarEventAsync( + container: StartedTestContainer, + calendarName: string, + event: CalendarTestEvent, +): Promise { + const host = container.getHost(); + const port = container.getMappedPort(80); + const id = createId(); + const url = `http://${host}:${port}/remote.php/dav/calendars/${DEFAULT_USERNAME}/${calendarName}/${id}.ics`; + + const auth = Buffer.from(`${DEFAULT_USERNAME}:${DEFAULT_PASSWORD}`).toString("base64"); + + // Build iCalendar format + const icalData = buildICalEvent(id, event); + + const response = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "text/calendar", + }, + body: icalData, + }); + + if (!response.ok && response.status !== 201) { + throw new Error(`Failed to create event: ${response.status} ${response.statusText}`); + } +} + +/** + * Builds an iCalendar format string from event data + */ +function buildICalEvent(uid: string, event: CalendarTestEvent): string { + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Homarr Tests//EN", + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`, + `DTSTART${event.timezone ? `;TZID=${event.timezone}` : ""}:${event.dtstart}`, + `DTEND${event.timezone ? `;TZID=${event.timezone}` : ""}:${event.dtend}`, + `SUMMARY:${event.summary}`, + ]; + + if (event.description) { + lines.push(`DESCRIPTION:${event.description}`); + } + + if (event.location) { + lines.push(`LOCATION:${event.location}`); + } + + if (event.color) { + lines.push(`COLOR:${event.color}`); + } + + if (event.rrule) { + lines.push(`RRULE:${event.rrule}`); + } + + lines.push("END:VEVENT", "END:VCALENDAR"); + + return lines.join("\r\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a374d09bd..dfde55696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1480,15 +1480,15 @@ importers: maria2: specifier: ^0.4.1 version: 0.4.1 - node-ical: - specifier: ^0.23.1 - version: 0.23.1 octokit: specifier: ^5.0.5 version: 5.0.5 proxmox-api: specifier: 1.1.1 version: 1.1.1 + timezones-ical-library: + specifier: ^2.1.3 + version: 2.1.3 tsdav: specifier: ^2.1.8 version: 2.1.8 @@ -3665,10 +3665,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@js-temporal/polyfill@0.5.1': - resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} - engines: {node: '>=12'} - '@jsep-plugin/assignment@1.3.0': resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} engines: {node: '>= 10.16.0'} @@ -7829,9 +7825,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsbi@4.3.2: - resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} - jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} @@ -8465,10 +8458,6 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-ical@0.23.1: - resolution: {integrity: sha512-4+FON2u++r7vU9VfTCris9hgxS5darN4P7aQZOJkzDazXLRS7cExb0G5OWO3CzRs8gHd5um1fdSMF0e45f7xAg==} - engines: {node: '>=18'} - node-loader@2.1.0: resolution: {integrity: sha512-OwjPkyh8+7jW8DMd/iq71uU1Sspufr/C2+c3t0p08J3CrM9ApZ4U53xuisNrDXOHyGi5OYHgtfmmh+aK9zJA6g==} engines: {node: '>= 10.13.0'} @@ -9500,9 +9489,6 @@ packages: rrdom@0.1.7: resolution: {integrity: sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==} - rrule-temporal@1.3.0: - resolution: {integrity: sha512-0duSf+wBX4FKNpznFDecEDkIzdoP7n3++Lt9TPpImhaRtENsa/qd3EhpV5Fi1TxVvBFN4ayOXVrK0aPvt+a80g==} - rrweb-player@1.0.0-alpha.4: resolution: {integrity: sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==} @@ -10076,6 +10062,10 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + timezones-ical-library@2.1.3: + resolution: {integrity: sha512-3ihogrr88TefPpa2og2fHw22VsWxlPUN4YDiLZRV9pbRsmguMyDsHhm8i0kkj7ihQYFgERZkj6eVOYY+TSOaOw==} + engines: {node: '>=22.18.0', npm: '>=10.9.3'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -12105,10 +12095,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@js-temporal/polyfill@0.5.1': - dependencies: - jsbi: 4.3.2 - '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': dependencies: jsep: 1.4.0 @@ -16946,8 +16932,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbi@4.3.2: {} - jsbn@1.1.0: {} jsdom@29.0.1(@noble/hashes@2.0.1): @@ -17702,11 +17686,6 @@ snapshots: node-gyp-build@4.8.4: {} - node-ical@0.23.1: - dependencies: - '@js-temporal/polyfill': 0.5.1 - rrule-temporal: 1.3.0 - node-loader@2.1.0(webpack@5.94.0): dependencies: loader-utils: 2.0.4 @@ -18813,10 +18792,6 @@ snapshots: dependencies: rrweb-snapshot: 2.0.0-alpha.4 - rrule-temporal@1.3.0: - dependencies: - '@js-temporal/polyfill': 0.5.1 - rrweb-player@1.0.0-alpha.4: dependencies: '@tsconfig/svelte': 1.0.13 @@ -19625,6 +19600,8 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + timezones-ical-library@2.1.3: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {}