Files
Cloud-CMS/lib/Widget/IcsProvider.php
Matt Batchelder 05ce0da296 Initial Upload
2025-12-02 10:32:59 -05:00

266 lines
11 KiB
PHP

<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Widget;
use Carbon\Carbon;
use GuzzleHttp\Exception\RequestException;
use ICal\ICal;
use Xibo\Helper\DateFormatHelper;
use Xibo\Support\Exception\ConfigurationException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Widget\DataType\Event;
use Xibo\Widget\Provider\DataProviderInterface;
use Xibo\Widget\Provider\DurationProviderNumItemsTrait;
use Xibo\Widget\Provider\WidgetProviderInterface;
use Xibo\Widget\Provider\WidgetProviderTrait;
/**
* Download and parse an ISC feed
*/
class IcsProvider implements WidgetProviderInterface
{
use WidgetProviderTrait;
use DurationProviderNumItemsTrait;
/**
* Fetch the ISC feed and load its data.
* @inheritDoc
*/
public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface
{
// Do we have a feed configured?
$uri = $dataProvider->getProperty('uri');
if (empty($uri)) {
throw new InvalidArgumentException(__('Please enter the URI to a valid ICS feed.'), 'uri');
}
// Create an ICal helper and pass it the contents of the file.
$iCalConfig = [
'replaceWindowsTimeZoneIds' => ($dataProvider->getProperty('replaceWindowsTimeZoneIds', 0) == 1),
'defaultSpan' => 1,
];
// What event range are we interested in?
// Decide on the Range we're interested in
// $iCal->eventsFromInterval only works for future events
$excludeAllDay = $dataProvider->getProperty('excludeAllDay', 0) == 1;
$excludePastEvents = $dataProvider->getProperty('excludePast', 0) == 1;
$startOfDay = match ($dataProvider->getProperty('startIntervalFrom')) {
'month' => Carbon::now()->startOfMonth(),
'week' => Carbon::now()->startOfWeek(),
default => Carbon::now()->startOfDay(),
};
// Force timezone of each event?
$useEventTimezone = $dataProvider->getProperty('useEventTimezone', 1);
// do we use interval or provided date range?
if ($dataProvider->getProperty('useDateRange')) {
$rangeStart = $dataProvider->getProperty('rangeStart');
$rangeStart = empty($rangeStart)
? Carbon::now()->startOfMonth()
: Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $rangeStart);
$rangeEnd = $dataProvider->getProperty('rangeEnd');
$rangeEnd = empty($rangeEnd)
? Carbon::now()->endOfMonth()
: Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $rangeEnd);
} else {
$interval = $dataProvider->getProperty('customInterval');
$rangeStart = $startOfDay->copy();
$rangeEnd = $rangeStart->copy()->add(
\DateInterval::createFromDateString(empty($interval) ? '1 week' : $interval)
);
}
$this->getLog()->debug('fetchData: final range, start=' . $rangeStart->toAtomString()
. ', end=' . $rangeEnd->toAtomString());
// Set up fuzzy filtering supported by the ICal library. This is included for performance.
// https://github.com/u01jmg3/ics-parser?tab=readme-ov-file#variables
$iCalConfig['filterDaysBefore'] = $rangeStart->diffInDays(Carbon::now(), false) + 2;
$iCalConfig['filterDaysAfter'] = $rangeEnd->diffInDays(Carbon::now()) + 2;
$this->getLog()->debug('Range start: ' . $rangeStart->toDateTimeString()
. ', range end: ' . $rangeEnd->toDateTimeString()
. ', config: ' . var_export($iCalConfig, true));
try {
$iCal = new ICal(false, $iCalConfig);
$iCal->initString($this->downloadIcs($uri, $dataProvider));
$this->getLog()->debug('Feed initialised');
// Before we parse anything - should we use the calendar timezone as a base for our calculations?
if ($dataProvider->getProperty('useCalendarTimezone') == 1) {
$iCal->defaultTimeZone = $iCal->calendarTimeZone();
}
$this->getLog()->debug('Calendar timezone set to: ' . $iCal->defaultTimeZone);
// Get an array of events
/** @var \ICal\Event[] $events */
$events = $iCal->eventsFromRange($rangeStart, $rangeEnd);
// Go through each event returned
foreach ($events as $event) {
try {
// Parse the ICal Event into our own data type object.
$entry = new Event();
$entry->summary = $event->summary;
$entry->description = $event->description;
$entry->location = $event->location;
// Parse out the start/end dates.
if ($useEventTimezone === 1) {
// Use the timezone from the event.
$entry->startDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtstart_array[3]));
$entry->endDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtend_array[3]));
} else {
// Use the parser calculated timezone shift
$entry->startDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtstart_tz));
$entry->endDate = Carbon::instance($iCal->iCalDateToDateTime($event->dtend_tz));
}
$this->getLog()->debug('Event: ' . $event->summary . ' with '
. $entry->startDate->format('c') . ' / ' . $entry->endDate->format('c'));
// Detect all day event
$isAllDay = false;
// If dtstart has value DATE
// (following RFC recommendations in https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.4 )
if (isset($event->dtstart_array[0])) {
// If it's a string
if (is_string($event->dtstart_array[0]) && strtoupper($event->dtstart_array[0]) === 'DATE') {
$isAllDay = true;
}
// If it's an array
if (is_array($event->dtstart_array[0]) &&
isset($event->dtstart_array[0]['VALUE']) &&
strtoupper($event->dtstart_array[0]['VALUE']) === 'DATE') {
$isAllDay = true;
}
}
// If MS extension flags it as all day
if (isset($event->x_microsoft_cdo_alldayevent) &&
is_string($event->x_microsoft_cdo_alldayevent) &&
strtoupper($event->x_microsoft_cdo_alldayevent) === 'TRUE') {
$isAllDay = true;
}
// Fallback: If both times are midnight and event is more than one day
if (!$isAllDay) {
$startAtMidnight = $entry->startDate->isStartOfDay();
$endsAtMidnight = $entry->endDate->isStartOfDay();
$diffDays = $entry->endDate->copy()->startOfDay()->diffInDays(
$entry->startDate->copy()->startOfDay()
);
$isAllDay = $startAtMidnight && $endsAtMidnight && $diffDays >= 1;
}
$entry->isAllDay = $isAllDay;
if ($excludeAllDay && $isAllDay) {
continue;
}
if ($excludePastEvents && $entry->endDate->isPast()) {
continue;
}
$dataProvider->addItem($entry);
} catch (\Exception $exception) {
$this->getLog()->error('Unable to parse event. ' . var_export($event, true));
}
}
$dataProvider->setCacheTtl($dataProvider->getProperty('updateInterval', 60) * 60);
$dataProvider->setIsHandled();
} catch (\Exception $exception) {
$this->getLog()->error('iscProvider: fetchData: ' . $exception->getMessage());
$this->getLog()->debug($exception->getTraceAsString());
$dataProvider->addError(__('The iCal provided is not valid, please choose a valid feed'));
}
return $this;
}
public function getDataCacheKey(DataProviderInterface $dataProvider): ?string
{
// No special cache key requirements.
return null;
}
/**
* @throws \Xibo\Support\Exception\GeneralException
*/
private function downloadIcs(string $uri, DataProviderInterface $dataProvider): string
{
// See if we have this ICS cached already.
$cache = $dataProvider->getPool()->getItem('/widget/' . $dataProvider->getDataType() . '/' . md5($uri));
$ics = $cache->get();
if ($cache->isMiss() || $ics === null) {
// Make a new request.
$this->getLog()->debug('downloadIcs: cache miss');
try {
// Create a Guzzle Client to get the Feed XML
$response = $dataProvider
->getGuzzleClient([
'timeout' => 20, // wait no more than 20 seconds
])
->get($uri);
$ics = $response->getBody()->getContents();
// Save the resonse to cache
$cache->set($ics);
$cache->expiresAfter($dataProvider->getSetting('cachePeriod', 1440) * 60);
$dataProvider->getPool()->saveDeferred($cache);
} catch (RequestException $requestException) {
// Log and return empty?
$this->getLog()->error('downloadIcs: Unable to get feed: ' . $requestException->getMessage());
$this->getLog()->debug($requestException->getTraceAsString());
throw new ConfigurationException(__('Unable to download feed'));
}
} else {
$this->getLog()->debug('downloadIcs: cache hit');
}
return $ics;
}
public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon
{
return null;
}
}