Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: add nextcloud integration #2501

Merged
merged 1 commit into from
Mar 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/nextjs/public/images/apps/nextcloud.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export const integrationDefs = {
category: ["healthMonitoring"],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
},
nextcloud: {
name: "Nextcloud",
secretKinds: [["username", "password"]],
category: ["calendar"],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
},
} as const satisfies Record<string, integrationDefinition>;

export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"node-ical": "^0.20.1",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.3",
"undici": "7.4.0",
"xml2js": "^0.6.2",
"zod": "^3.24.2"
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
Expand Down Expand Up @@ -86,6 +87,7 @@ export const integrationCreators = {
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";

// Types
export type { IntegrationInput } from "./base/integration";
Expand Down
97 changes: 97 additions & 0 deletions packages/integrations/src/nextcloud/nextcloud.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import dayjs from "dayjs";
import objectSupport from "dayjs/plugin/objectSupport";
import utc from "dayjs/plugin/utc";
import * as ical from "node-ical";
import { DAVClient } from "tsdav";

import { logger } from "@homarr/log";

import { Integration } from "../base/integration";
import type { CalendarEvent } from "../calendar-types";

dayjs.extend(utc);
dayjs.extend(objectSupport);

export class NextcloudIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const client = this.createCalendarClient();
await client.login();
}

public async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const client = this.createCalendarClient();
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): CalendarEvent => {
// @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.`);
}

logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`);

const date = dayjs.utc({
days: veventObject.start.getDay(),
month: veventObject.start.getMonth(),
year: veventObject.start.getFullYear(),
hours: veventObject.start.getHours(),
minutes: veventObject.start.getMinutes(),
seconds: veventObject.start.getSeconds(),
});

const eventUrlWithoutHost = new URL(event.url).pathname;
const dateInMillis = veventObject.start.valueOf();

const url = this.url(
`/apps/calendar/timeGridWeek/now/edit/sidebar/${Buffer.from(eventUrlWithoutHost).toString("base64url")}/${dateInMillis / 1000}`,
);

return {
name: veventObject.summary,
date: date.toDate(),
subName: "",
description: veventObject.description,
links: [
{
href: url.toString(),
name: "Nextcloud",
logo: "/images/apps/nextcloud.svg",
color: undefined,
notificationColor: "#ff8600",
isDark: true,
},
],
};
});
}

private createCalendarClient() {
return new DAVClient({
serverUrl: this.integration.url,
credentials: {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
},
authMethod: "Basic",
defaultAccountType: "caldav",
});
}
}
8 changes: 7 additions & 1 deletion packages/widgets/src/calendar/calendar-event-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
<Box pos={"relative"} w={70} h={120}>
<Image src={event.thumbnail} w={70} h={120} radius={"sm"} />
<Image
src={event.thumbnail}
w={70}
h={120}
radius={"sm"}
fallbackSrc={"https://placehold.co/400x600?text=No%20image"}
/>
{event.mediaInformation?.type === "tv" && (
<Badge
pos={"absolute"}
Expand Down
81 changes: 77 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.