feat(calendar): Add Nextcloud and iCal calendar services with event parsing

pull/963/head
TheItsNamless 2025-07-22 20:21:18 +02:00
parent c230392da8
commit 82721805f4
6 changed files with 3159 additions and 3425 deletions

View File

@ -14,6 +14,7 @@ within Homer:
- [Common options](#common-options) - [Common options](#common-options)
- [AdGuard Home](#adguard-home) - [AdGuard Home](#adguard-home)
- [CopyToClipboard](#copy-to-clipboard) - [CopyToClipboard](#copy-to-clipboard)
- [Calendar](#calendar)
- [Docuseal](#docuseal) - [Docuseal](#docuseal)
- [Docker Socket Proxy](#docker-socket-proxy) - [Docker Socket Proxy](#docker-socket-proxy)
- [Emby / Jellyfin](#emby--jellyfin) - [Emby / Jellyfin](#emby--jellyfin)
@ -107,6 +108,36 @@ Configuration example:
clipboard: "this text will be copied to your clipboard" clipboard: "this text will be copied to your clipboard"
``` ```
## Calendar
This service displays a calendar with events from either a Nextcloud calendar or an iCal feed.
You can retrieve a token for Nextcloud by going to your Nextcloud settings, then "Security" and generating a new app password.
To get the calendar URL, you can go to your Nextcloud calendar, click on the pen next to the calendar name, and select "Copy internal link".
### Nextcloud Calendar
```yaml
- name: "Nextcloud Calendar"
type: "NextcloudCalendar"
url: "https://cloud.example.com/remote.php/dav/calendars/username/calendarname"
user: "<---insert-username-here--->"
token: "<---insert-token-here--->"
```
### iCal Calendar
In case of iCal calendar, you do not need to provide a user or token, if the calendar has no basic authentication enabled.
```yaml
- name: "iCal Calendar"
type: "ICalendar"
url: "https://calendar.example.com/calendarname"
user: "<---insert-optional-username-here--->"
token: "<---insert-optional-token-here--->"
```
## Docker Socket Proxy ## Docker Socket Proxy
This service display the number of running, stopped and containers that have errors. This service display the number of running, stopped and containers that have errors.

View File

@ -13,6 +13,7 @@
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"bulma": "^1.0.4", "bulma": "^1.0.4",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"luxon": "^3.7.1",
"vue": "^3.5.14", "vue": "^3.5.14",
"yaml": "^2.8.0" "yaml": "^2.8.0"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
<template>
<Generic v-for="event in events" :key="event.name" :item="event"> </Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
import { DateTime } from "luxon";
import calendar from "../../mixins/iCalendarParser";
export default {
name: "ICalendar",
components: {
Generic,
},
mixins: [service, calendar],
props: {
item: Object,
},
data: () => ({
events: [],
}),
computed: {
calculatedLimit: function () {
const limit = parseInt(this.item.limit) || 5;
return Math.min(Math.max(limit, 1), 15);
},
iCalRequest: function () {
const headers = {
Authorization: this.item.user
? "Basic " + btoa(`${this.item.user}:${this.item.token}`)
: undefined,
};
return {
method: "GET",
headers,
};
},
},
created() {
this.fetchICalendarEvents();
},
methods: {
fetchICalendarEvents: async function () {
this.fetch("", this.iCalRequest, false).then((response) => {
const eventsResponse = this.parseICalendarResponse(response);
const filteredEvents = this.filterPastEvents(eventsResponse);
this.events = filteredEvents
.slice(0, this.calculatedLimit)
.map((event) => ({
name: event.title,
subtitle: event.description,
icon: "fas fa-calendar-plus",
quick: [
{
name: event.startString,
color: event.start < DateTime.now() ? "lightgreen" : "white",
},
],
}));
});
},
parseICalendarResponse: function (response) {
const eventBlocks =
response.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) || [];
const events = [];
for (let i = 0; i < eventBlocks.length; i++) {
const icalData = eventBlocks[i].trim();
events.push(this.extractEventFromICal(icalData));
}
events.sort((a, b) => {
return new Date(a.start) - new Date(b.start);
});
return events;
},
filterPastEvents: function (events) {
return events.filter((event) => {
return event.end >= DateTime.now();
});
},
},
};
</script>

View File

@ -0,0 +1,107 @@
<template>
<Generic v-for="event in events" :key="event.name" :item="event"> </Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
import { DateTime } from "luxon";
import calendar from "../../mixins/iCalendarParser";
export default {
name: "NextcloudCalendar",
components: {
Generic,
},
mixins: [service, calendar],
props: {
item: Object,
},
data: () => ({
events: [],
}),
computed: {
calculatedLimit: function () {
const limit = parseInt(this.item.limit) || 5;
return Math.min(Math.max(limit, 1), 15);
},
davRequest: function () {
const headers = {
Authorization: "Basic " + btoa(`${this.item.user}:${this.item.token}`),
"Content-Type": "text/xml",
Accept: "application/xml",
Depth: "1",
};
const startDate = DateTime.utc().toFormat("yyyyMMdd'T'HHmmss'Z'");
const body = `
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="${startDate}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
`;
return {
method: "REPORT",
headers,
body: body,
};
},
},
created() {
this.fetchCalDavEvents();
},
methods: {
fetchCalDavEvents: async function () {
this.fetch("", this.davRequest, false).then((response) => {
const eventsResponse = this.parseCalDavResponse(response);
this.events = eventsResponse
.slice(0, this.calculatedLimit)
.map((event) => ({
name: event.title,
subtitle: event.description,
icon: "fas fa-calendar-plus",
quick: [
{
name: event.startString,
color: event.start < DateTime.now() ? "lightgreen" : "white",
},
],
}));
});
},
parseCalDavResponse: function (response) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(response, "application/xml");
// Get all event nodes
const calendarData = xmlDoc.getElementsByTagName("cal:calendar-data");
// Store event details
const events = [];
for (let i = 0; i < calendarData.length; i++) {
const icalData = calendarData[i].textContent.trim();
events.push(this.extractEventFromICal(icalData));
}
events.sort((a, b) => {
return new Date(a.start) - new Date(b.start);
});
return events;
},
},
};
</script>

View File

@ -0,0 +1,85 @@
import { DateTime } from "luxon";
export default {
methods: {
extractEventFromICal: function (rawICal) {
const vevent = this.extractVevent(rawICal);
const title = this.extractText(vevent, "SUMMARY");
const [start, startString] = this.extractTime(vevent, "DTSTART");
// eslint-disable-next-line no-unused-vars
const [end, _] = this.extractTime(vevent, "DTEND");
const description = this.extractText(vevent, "DESCRIPTION");
const event = {
title: title,
start: start,
end: end,
startString: startString,
description: description,
};
return event;
},
extractVevent: function (data) {
const startMarker = "BEGIN:VEVENT";
const endMarker = "END:VEVENT";
const regex = new RegExp(`${startMarker}(.*?)${endMarker}`, "s");
const match = data.match(regex);
return match ? match[1].trim() : null;
},
extractText: function (data, propertyName) {
const regex = new RegExp(`${propertyName}:(.*?)(\\r?\\n|$)`);
const match = data.match(regex);
return match ? match[1].trim() : null;
},
extractTime: function (data, propertyName) {
// Match propertyName with optional parameters and extract the value after ':'
// Example matches: DTSTART;TZID=Europe/Berlin:20030201T010000, DTSTART;VALUE=DATE:20030201
const partsMatch = data.match(`${propertyName}(?:(;[^:]*)?)?:([0-9T]+)`);
if (!partsMatch) {
return null;
}
// Extract timezone
let timeZone = "UTC";
if (partsMatch[1]) {
const tzidMatch = partsMatch[1].match(/;TZID=([^;:]*)/);
if (tzidMatch) {
timeZone = tzidMatch[1];
}
}
const dateTimeStr = partsMatch[2];
// If it's a date-only value, parse as Date (YYYYMMDD)
if (partsMatch[1] && partsMatch[1].includes("VALUE=DATE")) {
const parsedDate = DateTime.fromFormat(dateTimeStr, "yyyyMMdd", {
zone: timeZone,
});
return [
parsedDate,
parsedDate.toLocaleString({
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
}),
];
}
const parsedDateTime = DateTime.fromFormat(
dateTimeStr,
"yyyyMMdd'T'HHmmss",
{ zone: timeZone },
);
return [
parsedDateTime,
parsedDateTime.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY, {
timeZone,
}),
];
},
},
};