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)
|
||||
- [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.
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
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