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)
- [AdGuard Home](#adguard-home)
- [CopyToClipboard](#copy-to-clipboard)
- [Calendar](#calendar)
- [Docuseal](#docuseal)
- [Docker Socket Proxy](#docker-socket-proxy)
- [Emby / Jellyfin](#emby--jellyfin)
@ -107,6 +108,36 @@ Configuration example:
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
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",
"bulma": "^1.0.4",
"lodash.merge": "^4.6.2",
"luxon": "^3.7.1",
"vue": "^3.5.14",
"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,
}),
];
},
},
};