fix: node-ical prevents many trpc queries from loading (#5395)
This commit is contained in:
1 parent
e92c407beb
commit
c977b84e2d
4 files changed
+521
-50
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof import("@homarr/db")>();
|
||||
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<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
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<StartedTestContainer> {
|
||||
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<void> {
|
||||
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: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:mkcalendar xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>${calendarName}</d:displayname>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</c:mkcalendar>`,
|
||||
});
|
||||
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
Generated
+9
-32
@@ -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: {}
|
||||
|
||||
Reference in new issue
Block a user