Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
207 lines
7.5 KiB
TypeScript
207 lines
7.5 KiB
TypeScript
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 { tzlib_get_ical_block, tzlib_get_timezones } from "timezones-ical-library";
|
|
import { DAVClient } from "tsdav";
|
|
import { z } from "zod";
|
|
|
|
import { ResponseError } from "@homarr/common/server";
|
|
import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
|
|
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
|
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
|
|
import type { IntegrationTestingInput } from "../base/integration";
|
|
import { Integration } from "../base/integration";
|
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
|
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
|
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(
|
|
z.object({
|
|
notification_id: z.number(),
|
|
datetime: z.string(),
|
|
subject: z.string(),
|
|
message: z.string(),
|
|
}),
|
|
),
|
|
}),
|
|
});
|
|
|
|
const logger = createLogger({ module: "nextcloudIntegration" });
|
|
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
|
|
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
|
|
export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration {
|
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
|
const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options));
|
|
await client.login();
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
public async getCalendarEventsAsync(start: Date, end: Date, _showUnmonitored?: boolean): Promise<CalendarEvent[]> {
|
|
const client = await this.createCalendarClientAsync();
|
|
await client.login();
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
// Parameters must be in ISO-8601, See https://tsdav.vercel.app/docs/caldav/fetchCalendarObjects#arguments
|
|
const calendarEvents = (
|
|
await Promise.all(
|
|
calendars.map(
|
|
async (calendar) =>
|
|
await client.fetchCalendarObjects({
|
|
calendar,
|
|
timeRange: { start: start.toISOString(), end: end.toISOString() },
|
|
}),
|
|
),
|
|
)
|
|
).flat();
|
|
|
|
return calendarEvents
|
|
.map((event) => {
|
|
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 durationMs = veventObject.duration.toSeconds() * 1000;
|
|
|
|
// 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.isDate
|
|
? next.toJSDate()
|
|
: 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.isDate
|
|
? veventObject.startDate.toJSDate()
|
|
: veventObject.startDate.convertToZone(ICAL.Timezone.utcTimezone).toJSDate(),
|
|
];
|
|
}
|
|
|
|
return startDates.map((startDate) => {
|
|
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 || null,
|
|
startDate,
|
|
endDate,
|
|
image: null,
|
|
location: veventObject.location || null,
|
|
indicatorColor: color ?? "#ff8600",
|
|
links: [
|
|
{
|
|
href: this.externalUrl(
|
|
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
|
|
).toString(),
|
|
name: "Nextcloud",
|
|
logo: "/images/apps/nextcloud.svg",
|
|
color: undefined,
|
|
isDark: true,
|
|
},
|
|
],
|
|
};
|
|
});
|
|
})
|
|
.flat();
|
|
}
|
|
|
|
public async getNotificationsAsync(): Promise<Notification[]> {
|
|
const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" });
|
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(
|
|
`${this.getSecretValue("username")}:${this.getSecretValue("password")}`,
|
|
).toString("base64")}`,
|
|
"OCS-APIRequest": "true",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) throw new ResponseError(response);
|
|
|
|
const json = notificationSchema.parse(await response.json());
|
|
|
|
return json.ocs.data.map((notification) => ({
|
|
id: String(notification.notification_id),
|
|
time: new Date(notification.datetime),
|
|
title: notification.subject,
|
|
body: notification.message,
|
|
}));
|
|
}
|
|
|
|
private async createCalendarClientAsync(agent?: Agent) {
|
|
return new DAVClient({
|
|
serverUrl: this.integration.url,
|
|
credentials: {
|
|
username: this.getSecretValue("username"),
|
|
password: this.getSecretValue("password"),
|
|
},
|
|
authMethod: "Basic",
|
|
defaultAccountType: "caldav",
|
|
fetchOptions: {
|
|
// tsdav is using cross-fetch which uses node-fetch for nodejs environments.
|
|
// There is an agent property that is the same type as the http(s) agents of nodejs
|
|
agent: agent ?? (await createHttpsAgentAsync()),
|
|
} satisfies NodeFetchRequestInit as RequestInit,
|
|
});
|
|
}
|
|
}
|