mirror of https://github.com/bastienwirtz/homer
feat(calendar): Add Nextcloud and iCal calendar services with event parsing
parent
c230392da8
commit
82721805f4
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
6271
pnpm-lock.yaml
6271
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
Loading…
Reference in New Issue