diff --git a/apps/nextjs/public/images/apps/nextcloud.svg b/apps/nextjs/public/images/apps/nextcloud.svg
new file mode 100644
index 000000000..841b9d987
--- /dev/null
+++ b/apps/nextjs/public/images/apps/nextcloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index e4bda472a..ab2097f73 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -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;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf;
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index f8d4a1adf..677a0f27d 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -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"
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
index 21df2a7e9..7664e86bc 100644
--- a/packages/integrations/src/base/creator.ts
+++ b/packages/integrations/src/base/creator.ts
@@ -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";
@@ -86,6 +87,7 @@ export const integrationCreators = {
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
+ nextcloud: NextcloudIntegration,
} satisfies Record Promise]>;
type IntegrationInstanceOfKind = {
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index 8a52261c3..23f24b712 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -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";
diff --git a/packages/integrations/src/nextcloud/nextcloud.integration.ts b/packages/integrations/src/nextcloud/nextcloud.integration.ts
new file mode 100644
index 000000000..7f6268a6d
--- /dev/null
+++ b/packages/integrations/src/nextcloud/nextcloud.integration.ts
@@ -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 {
+ const client = this.createCalendarClient();
+ await client.login();
+ }
+
+ public async getCalendarEventsAsync(start: Date, end: Date): Promise {
+ 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",
+ });
+ }
+}
diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx
index 22eaa8660..6f7906b10 100644
--- a/packages/widgets/src/calendar/calendar-event-list.tsx
+++ b/packages/widgets/src/calendar/calendar-event-list.tsx
@@ -41,7 +41,13 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
{events.map((event, eventIndex) => (
-
+
{event.mediaInformation?.type === "tv" && (
= 0.6.0'}
@@ -5649,6 +5658,9 @@ packages:
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
+ cross-fetch@4.0.0:
+ resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
+
cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
@@ -7631,6 +7643,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ moment-timezone@0.5.47:
+ resolution: {integrity: sha512-UbNt/JAWS0m/NJOebR0QMRHBk0hu03r5dx9GK8Cs0AS3I81yDcOc9k+DytPItgVvBP7J6Mf6U2n3BPAacAV9oA==}
+
+ moment@2.30.1:
+ resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+
mpd-parser@1.3.1:
resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==}
hasBin: true
@@ -7815,6 +7833,9 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
+ node-ical@0.20.1:
+ resolution: {integrity: sha512-NrXgzDJd6XcyX9kDMJVA3xYCZmntY7ghA2BOdBeYr3iu8tydHOAb+68jPQhF9V2CRQ0/386X05XhmLzQUN0+Hw==}
+
node-loader@2.1.0:
resolution: {integrity: sha512-OwjPkyh8+7jW8DMd/iq71uU1Sspufr/C2+c3t0p08J3CrM9ApZ4U53xuisNrDXOHyGi5OYHgtfmmh+aK9zJA6g==}
engines: {node: '>= 10.13.0'}
@@ -8795,6 +8816,9 @@ packages:
rrdom@0.1.7:
resolution: {integrity: sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==}
+ rrule@2.8.1:
+ resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
+
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
@@ -9517,6 +9541,10 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+ tsdav@2.1.3:
+ resolution: {integrity: sha512-TwPBYZKLlbJNtmfg5QzeGqRnOYZ4CCuII3D528+Vv8K/les0PmyB7sT7gf967a6SduJKxCVovWZ+Ei3O+cCAlg==}
+ engines: {node: '>=10'}
+
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
@@ -10129,6 +10157,10 @@ packages:
xml-but-prettier@1.0.1:
resolution: {integrity: sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==}
+ xml-js@1.6.11:
+ resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+ hasBin: true
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -11220,7 +11252,7 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
- '@mantine/notifications@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ '@mantine/notifications@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@mantine/core': 7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@mantine/hooks': 7.17.1(react@19.0.0)
@@ -11229,7 +11261,7 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
- '@mantine/spotlight@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ '@mantine/spotlight@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@mantine/core': 7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@mantine/hooks': 7.17.1(react@19.0.0)
@@ -13468,6 +13500,8 @@ snapshots:
streamx: 2.20.1
optional: true
+ base-64@1.0.0: {}
+
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
@@ -13929,6 +13963,12 @@ snapshots:
dependencies:
cross-spawn: 7.0.3
+ cross-fetch@4.0.0:
+ dependencies:
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
+
cross-fetch@4.1.0:
dependencies:
node-fetch: 2.7.0
@@ -16173,6 +16213,12 @@ snapshots:
mkdirp@1.0.4: {}
+ moment-timezone@0.5.47:
+ dependencies:
+ moment: 2.30.1
+
+ moment@2.30.1: {}
+
mpd-parser@1.3.1:
dependencies:
'@babel/runtime': 7.25.6
@@ -16338,6 +16384,15 @@ snapshots:
node-gyp-build@4.8.4:
optional: true
+ node-ical@0.20.1:
+ dependencies:
+ axios: 1.7.7
+ moment-timezone: 0.5.47
+ rrule: 2.8.1
+ uuid: 10.0.0
+ transitivePeerDependencies:
+ - debug
+
node-loader@2.1.0(webpack@5.94.0):
dependencies:
loader-utils: 2.0.4
@@ -17386,6 +17441,10 @@ snapshots:
dependencies:
rrweb-snapshot: 2.0.0-alpha.4
+ rrule@2.8.1:
+ dependencies:
+ tslib: 2.8.1
+
rrweb-cssom@0.8.0: {}
rrweb-player@1.0.0-alpha.4:
@@ -18313,6 +18372,16 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
+ tsdav@2.1.3:
+ dependencies:
+ base-64: 1.0.0
+ cross-fetch: 4.0.0
+ debug: 4.4.0
+ xml-js: 1.6.11
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
tslib@1.14.1: {}
tslib@2.7.0: {}
@@ -18981,6 +19050,10 @@ snapshots:
dependencies:
repeat-string: 1.6.1
+ xml-js@1.6.11:
+ dependencies:
+ sax: 1.4.1
+
xml-name-validator@5.0.0: {}
xml2js@0.6.2: