diff --git a/package.json b/package.json index 6a87fa5cc..6e9394586 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ "dependencies": { "@headlessui/react": "^1.7.19", "@kubernetes/client-node": "^1.0.0", - "cal-parser": "^1.0.2", "classnames": "^2.5.1", "compare-versions": "^6.1.1", "dockerode": "^4.0.4", "follow-redirects": "^1.15.9", "gamedig": "^5.2.0", "i18next": "^24.2.3", + "ical.js": "^2.1.0", "js-yaml": "^4.1.0", "json-rpc-2.0": "^1.7.0", "luxon": "^3.5.0", @@ -35,7 +35,6 @@ "react-i18next": "^11.18.6", "react-icons": "^5.4.0", "recharts": "^2.15.3", - "rrule": "^2.8.1", "swr": "^2.3.3", "systeminformation": "^5.25.11", "tough-cookie": "^5.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c932b0c72..e3387dea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@kubernetes/client-node': specifier: ^1.0.0 version: 1.0.0 - cal-parser: - specifier: ^1.0.2 - version: 1.0.2 classnames: specifier: ^2.5.1 version: 2.5.1 @@ -35,6 +32,9 @@ importers: i18next: specifier: ^24.2.3 version: 24.2.3(typescript@5.7.3) + ical.js: + specifier: ^2.1.0 + version: 2.1.0 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -80,9 +80,6 @@ importers: recharts: specifier: ^2.15.3 version: 2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rrule: - specifier: ^2.8.1 - version: 2.8.1 swr: specifier: ^2.3.3 version: 2.3.3(react@18.3.1) @@ -970,9 +967,6 @@ packages: resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} engines: {node: '>=14.16'} - cal-parser@1.0.2: - resolution: {integrity: sha512-wlQwcF0fl4eLclyGdncF9rcNNq0ipRYZGagG6h3LVgRXvCWE1fdMUaCLXwfC9YWoz9jKKbjQAq7TpO2Y3yrvmA==} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1670,8 +1664,8 @@ packages: typescript: optional: true - ical-date-parser@4.0.0: - resolution: {integrity: sha512-XRCK/FU1akC2ZaJOdKIeZI6BLLgzWUuE0pegSrrkEva89GOan5mNkLVqCU4EMhCJ9nkG5TLWdMXrVX1fNAkFzw==} + ical.js@2.1.0: + resolution: {integrity: sha512-BOVfrH55xQ6kpS3muGvIXIg2l7p+eoe12/oS7R5yrO3TL/j/bLsR0PR+tYQESFbyTbvGgPHn9zQ6tI4FWyuSaQ==} iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} @@ -2424,9 +2418,6 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true - rrule@2.8.1: - resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3680,11 +3671,6 @@ snapshots: normalize-url: 8.0.1 responselike: 3.0.0 - cal-parser@1.0.2: - dependencies: - ical-date-parser: 4.0.0 - rrule: 2.8.1 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4557,7 +4543,7 @@ snapshots: optionalDependencies: typescript: 5.7.3 - ical-date-parser@4.0.0: {} + ical.js@2.1.0: {} iconv-lite@0.6.3: dependencies: @@ -5306,10 +5292,6 @@ snapshots: dependencies: glob: 10.4.5 - rrule@2.8.1: - dependencies: - tslib: 2.8.1 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5eab037df..4b1758239 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -856,7 +856,8 @@ "physicalRelease": "Physical release", "digitalRelease": "Digital release", "noEventsToday": "No events for today!", - "noEventsFound": "No events found" + "noEventsFound": "No events found", + "errorWhenLoadingData": "Error when loading calendar data" }, "romm": { "platforms": "Platforms", diff --git a/src/widgets/calendar/integrations/ical.jsx b/src/widgets/calendar/integrations/ical.jsx index 462179776..3a3309e6c 100644 --- a/src/widgets/calendar/integrations/ical.jsx +++ b/src/widgets/calendar/integrations/ical.jsx @@ -1,21 +1,20 @@ -import { parseString } from "cal-parser"; +import ICAL from "ical.js"; import { DateTime } from "luxon"; import { useTranslation } from "next-i18next"; import { useEffect } from "react"; -import { RRule } from "rrule"; import Error from "../../../components/services/widget/error"; import useWidgetAPI from "../../../utils/proxy/use-widget-api"; -// https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781 function simpleHash(str) { - /* eslint-disable no-plusplus, no-bitwise */ let hash = 0; + const prime = 31; + for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + hash = (hash * prime + str.charCodeAt(i)) % 2_147_483_647; } - return (hash >>> 0).toString(36); - /* eslint-disable no-plusplus, no-bitwise */ + + return Math.abs(hash).toString(36); } export default function Integration({ config, params, setEvents, hideErrors, timezone }) { @@ -25,11 +24,49 @@ export default function Integration({ config, params, setEvents, hideErrors, tim }); useEffect(() => { - let parsedIcal; + const { showName = false } = config?.params || {}; + let events = []; if (!icalError && icalData && !icalData.error) { - parsedIcal = parseString(icalData.data); - if (parsedIcal.events.length === 0) { + if (!icalData.data) { + icalData.error = { message: `'${config.name}': ${t("calendar.errorWhenLoadingData")}` }; + return; + } + + const jCal = ICAL.parse(icalData.data); + const vCalendar = new ICAL.Component(jCal); + + const buildEvent = (event, type) => { + return { + id: event.getFirstPropertyValue("uid"), + type, + title: event.getFirstPropertyValue("summary"), + rrule: event.getFirstPropertyValue("rrule"), + dtstart: + event.getFirstPropertyValue("dtstart") || + event.getFirstPropertyValue("due") || + event.getFirstPropertyValue("completed") || + ICAL.Time.now(), // handles events without a date + dtend: + event.getFirstPropertyValue("dtend") || + event.getFirstPropertyValue("due") || + event.getFirstPropertyValue("completed") || + ICAL.Time.now(), // handles events without a date + location: event.getFirstPropertyValue("location"), + status: event.getFirstPropertyValue("status"), + }; + }; + + const getEvents = () => { + const vEvents = vCalendar.getAllSubcomponents("vevent").map((event) => buildEvent(event, "vevent")); + + const vTodos = vCalendar.getAllSubcomponents("vtodo").map((todo) => buildEvent(todo, "vtodo")); + + return [...vEvents, ...vTodos]; + }; + + events = getEvents(); + if (events.length === 0) { icalData.error = { message: `'${config.name}': ${t("calendar.noEventsFound")}` }; } } @@ -37,72 +74,67 @@ export default function Integration({ config, params, setEvents, hideErrors, tim const startDate = DateTime.fromISO(params.start); const endDate = DateTime.fromISO(params.end); - if (icalError || !parsedIcal || !startDate.isValid || !endDate.isValid) { + if (icalError || events.length === 0 || !startDate.isValid || !endDate.isValid) { return; } - const eventsToAdd = {}; - const events = parsedIcal?.getEventsBetweenDates(startDate.toJSDate(), endDate.toJSDate()); - const now = timezone ? DateTime.now().setZone(timezone) : DateTime.now(); + const rangeStart = ICAL.Time.fromJSDate(startDate.toJSDate()); + const rangeEnd = ICAL.Time.fromJSDate(endDate.toJSDate()); - events?.forEach((event) => { - let title = `${event?.summary?.value}`; - if (config?.params?.showName) { - title = `${config.name}: ${title}`; - } - - // 'dtend' is null for all-day events - const { dtstart, dtend = { value: 0 } } = event; - - const eventToAdd = (date, i, type) => { - const days = dtend.value === 0 ? 1 : (dtend.value - dtstart.value) / (1000 * 60 * 60 * 24); - const eventDate = timezone ? DateTime.fromJSDate(date, { zone: timezone }) : DateTime.fromJSDate(date); - - for (let j = 0; j < days; j += 1) { - // See https://github.com/gethomepage/homepage/issues/2753 uid is not stable - // assumption is that the event is the same if the start, end and title are all the same - const hash = simpleHash(`${dtstart?.value}${dtend?.value}${title}${i}${j}${type}}`); - eventsToAdd[hash] = { - title, - date: eventDate.plus({ days: j }), - color: config?.color ?? "zinc", - isCompleted: eventDate < now, - additional: event.location?.value, - type: "ical", - }; + const getOcurrencesFromRange = (event) => { + if (!event.rrule) { + if (event.dtstart.compare(rangeStart) >= 0 && event.dtend.compare(rangeEnd) <= 0) { + return [event.dtstart]; } - }; - let recurrenceOptions = event?.recurrenceRule?.origOptions; - // RRuleSet does not have dtstart, add it manually - if (event?.recurrenceRule && event.recurrenceRule.rrules && event.recurrenceRule.rrules()?.[0]?.origOptions) { - recurrenceOptions = event.recurrenceRule.rrules()[0].origOptions; - recurrenceOptions.dtstart = dtstart.value; + return []; } - if (recurrenceOptions && Object.keys(recurrenceOptions).length !== 0) { - try { - const rule = new RRule(recurrenceOptions); - const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate()); + const iterator = event.rrule.iterator(event.dtstart); - recurringEvents.forEach((date, i) => { - let eventDate = date; - if (event.dtstart?.params?.tzid) { - // date is in UTC but parsed as if it is in current timezone, so we need to adjust it - const dateInUTC = DateTime.fromJSDate(date).setZone("UTC"); - const offset = dateInUTC.offset - DateTime.fromJSDate(date, { zone: event.dtstart.params.tzid }).offset; - eventDate = dateInUTC.plus({ minutes: offset }).toJSDate(); - } - eventToAdd(eventDate, i, "recurring"); - }); - return; - } catch (e) { - // eslint-disable-next-line no-console - console.error("Unable to parse recurring events from iCal: %s", e); + const occurrences = []; + for (let next = iterator.next(); next && next.compare(rangeEnd) < 0; next = iterator.next()) { + if (next.compare(rangeStart) < 0) { + continue; } + + occurrences.push(next.clone()); } - event.matchingDates.forEach((date, i) => eventToAdd(date, i, "single")); + return occurrences; + }; + + const eventsToAdd = []; + events.forEach((event, index) => { + const occurrences = getOcurrencesFromRange(event); + + occurrences.forEach((icalDate) => { + const date = icalDate.toJSDate(); + + const hash = simpleHash(`${event.id}-${event.title}-${index}-${date.toString()}`); + + let title = event.title; + if (showName) { + title = `${config.name}: ${title}`; + } + + const getIsCompleted = () => { + if (event.type === "vtodo") { + return event.status === "COMPLETED"; + } + + return DateTime.fromJSDate(date) < DateTime.now(); + }; + + eventsToAdd[hash] = { + title, + date: DateTime.fromJSDate(date), + color: config?.color ?? "zinc", + isCompleted: getIsCompleted(), + additional: event.location, + type: "ical", + }; + }); }); setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));