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: