feat(#129): add iCal response parser

This commit is contained in:
Benjamin Nussbaum 2025-12-09 15:05:14 +01:00
parent 838db288e7
commit 60f2a38169
12 changed files with 513 additions and 49 deletions

View file

@ -131,6 +131,6 @@ class Data extends FiltersProvider
*/
public function map_to_i(array $input): array
{
return array_map('intval', $input);
return array_map(intval(...), $input);
}
}

View file

@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use App\Services\Plugin\Parsers\ResponseParserRegistry;
use App\Services\PluginImportService;
use Carbon\Carbon;
use Exception;
@ -26,7 +27,6 @@ use Illuminate\Support\Str;
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Extensions\StandardExtension;
use SimpleXMLElement;
class Plugin extends Model
{
@ -216,60 +216,25 @@ class Plugin extends Model
}
}
/**
* Parse HTTP response, handling both JSON and XML content types
*/
private function parseResponse(Response $httpResponse): array
{
if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) {
try {
// Convert XML to array and wrap under 'rss' key
$xml = simplexml_load_string($httpResponse->body());
if ($xml === false) {
throw new Exception('Invalid XML content');
}
$parsers = app(ResponseParserRegistry::class)->getParsers();
// Convert SimpleXML directly to array
$xmlArray = $this->xmlToArray($xml);
return ['rss' => $xmlArray];
} catch (Exception $e) {
Log::warning('Failed to parse XML response: '.$e->getMessage());
return ['error' => 'Failed to parse XML response'];
}
}
foreach ($parsers as $parser) {
$parserName = class_basename($parser);
try {
// Attempt to parse it into JSON
$json = $httpResponse->json();
if ($json !== null) {
return $json;
}
$result = $parser->parse($httpResponse);
// Response doesn't seem to be JSON, wrap the response body text as a JSON object
return ['data' => $httpResponse->body()];
if ($result !== null) {
return $result;
}
} catch (Exception $e) {
Log::warning('Failed to parse JSON response: '.$e->getMessage());
return ['error' => 'Failed to parse JSON response'];
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
}
}
/**
* Convert SimpleXML object to array recursively
*/
private function xmlToArray(SimpleXMLElement $xml): array
{
$array = (array) $xml;
foreach ($array as $key => $value) {
if ($value instanceof SimpleXMLElement) {
$array[$key] = $this->xmlToArray($value);
}
}
return $array;
return ['error' => 'Failed to parse response'];
}
/**

View file

@ -0,0 +1,111 @@
<?php
namespace App\Services\Plugin\Parsers;
use Carbon\Carbon;
use DateTimeInterface;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use om\IcalParser;
class IcalResponseParser implements ResponseParser
{
public function __construct(
private readonly IcalParser $parser = new IcalParser(),
) {}
public function parse(Response $response): ?array
{
$contentType = $response->header('Content-Type');
$body = $response->body();
if (! $this->isIcalResponse($contentType, $body)) {
return null;
}
try {
$this->parser->parseString($body);
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
$windowStart = now()->subDays(7);
$windowEnd = now()->addDays(30);
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
if (!$startDate instanceof \Carbon\Carbon) {
return false;
}
return $startDate->between($windowStart, $windowEnd, true);
}));
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
return ['ical' => $normalizedEvents];
} catch (Exception $exception) {
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
return ['error' => 'Failed to parse iCal response'];
}
}
private function isIcalResponse(?string $contentType, string $body): bool
{
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
return true;
}
return str_contains($body, 'BEGIN:VCALENDAR');
}
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value);
}
if (is_string($value) && $value !== '') {
try {
return Carbon::parse($value);
} catch (Exception $exception) {
Log::warning('Failed to parse date value: '.$exception->getMessage());
return null;
}
}
return null;
}
private function normalizeIcalEvent(array $event): array
{
$normalized = [];
foreach ($event as $key => $value) {
$normalized[$key] = $this->normalizeIcalValue($value);
}
return $normalized;
}
private function normalizeIcalValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value)->toAtomString();
}
if (is_array($value)) {
return array_map($this->normalizeIcalValue(...), $value);
}
return $value;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Services\Plugin\Parsers;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
class JsonOrTextResponseParser implements ResponseParser
{
public function parse(Response $response): array
{
try {
$json = $response->json();
if ($json !== null) {
return $json;
}
return ['data' => $response->body()];
} catch (Exception $e) {
Log::warning('Failed to parse JSON response: '.$e->getMessage());
return ['error' => 'Failed to parse JSON response'];
}
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Services\Plugin\Parsers;
use Illuminate\Http\Client\Response;
interface ResponseParser
{
/**
* Attempt to parse the given response.
*
* Return null when the parser is not applicable so other parsers can run.
*/
public function parse(Response $response): ?array;
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Services\Plugin\Parsers;
class ResponseParserRegistry
{
/**
* @var array<int, ResponseParser>
*/
private readonly array $parsers;
/**
* @param array<int, ResponseParser> $parsers
*/
public function __construct(array $parsers = [])
{
$this->parsers = $parsers ?: [
new XmlResponseParser(),
new IcalResponseParser(),
new JsonOrTextResponseParser(),
];
}
/**
* @return array<int, ResponseParser>
*/
public function getParsers(): array
{
return $this->parsers;
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Services\Plugin\Parsers;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use SimpleXMLElement;
class XmlResponseParser implements ResponseParser
{
public function parse(Response $response): ?array
{
$contentType = $response->header('Content-Type');
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
return null;
}
try {
$xml = simplexml_load_string($response->body());
if ($xml === false) {
throw new Exception('Invalid XML content');
}
return ['rss' => $this->xmlToArray($xml)];
} catch (Exception $exception) {
Log::warning('Failed to parse XML response: '.$exception->getMessage());
return ['error' => 'Failed to parse XML response'];
}
}
private function xmlToArray(SimpleXMLElement $xml): array
{
$array = (array) $xml;
foreach ($array as $key => $value) {
if ($value instanceof SimpleXMLElement) {
$array[$key] = $this->xmlToArray($value);
}
}
return $array;
}
}

View file

@ -23,6 +23,7 @@
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/volt": "^1.7",
"om/icalparser": "^3.2",
"spatie/browsershot": "^5.0",
"symfony/yaml": "^7.3",
"wnx/sidecar-browsershot": "^2.6"

53
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7750ff686c4cad7f85390488c28b33ca",
"content-hash": "3e4c22c016c04e49512b5fcd20983baa",
"packages": [
{
"name": "aws/aws-crt-php",
@ -3710,6 +3710,57 @@
],
"time": "2025-11-20T02:34:59+00:00"
},
{
"name": "om/icalparser",
"version": "v3.2.0",
"source": {
"type": "git",
"url": "https://github.com/OzzyCzech/icalparser.git",
"reference": "3aa0716aa9e729f08fba20390773d6dcd685169b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/3aa0716aa9e729f08fba20390773d6dcd685169b",
"reference": "3aa0716aa9e729f08fba20390773d6dcd685169b",
"shasum": ""
},
"require": {
"php": ">=8.1.0"
},
"require-dev": {
"nette/tester": "^2.5.6"
},
"suggest": {
"ext-dom": "for timezone tool"
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Roman Ožana",
"email": "roman@ozana.cz"
}
],
"description": "Simple iCal parser",
"keywords": [
"calendar",
"ical",
"parser"
],
"support": {
"issues": "https://github.com/OzzyCzech/icalparser/issues",
"source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.0"
},
"time": "2025-09-08T07:04:53+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",

View file

@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
'flux_icon_name' => 'flower',
]
);
Plugin::updateOrCreate(
[
'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90',
'name' => 'Holidays (iCal)',
'user_id' => $user_id,
'data_payload' => null,
'data_stale_minutes' => 720,
'data_strategy' => 'polling',
'configuration_template' => [
'custom_fields' => [
[
'keyname' => 'calendar',
'field_type' => 'select',
'name' => 'Public Holidays Calendar',
'options' => [
['USA' => 'usa'],
['Austria' => 'austria'],
['Australia' => 'australia'],
['Canada' => 'canada'],
['Germany' => 'germany'],
['UK' => 'united-kingdom'],
],
],
],
],
'configuration' => ['calendar' => 'usa'],
'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}',
'polling_verb' => 'get',
'polling_header' => null,
'render_markup' => null,
'render_markup_view' => 'recipes.holidays-ical',
'detail_view_route' => null,
'icon_url' => null,
'flux_icon_name' => 'calendar',
]
);
}
}

View file

@ -0,0 +1,87 @@
@props(['size' => 'full'])
@php
use Carbon\Carbon;
$events = collect($data['ical'] ?? [])
->map(function (array $event): array {
$start = null;
$end = null;
try {
$start = isset($event['DTSTART']) ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) : null;
} catch (Exception $e) {
$start = null;
}
try {
$end = isset($event['DTEND']) ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) : null;
} catch (Exception $e) {
$end = null;
}
return [
'summary' => $event['SUMMARY'] ?? 'Untitled event',
'location' => $event['LOCATION'] ?? null,
'start' => $start,
'end' => $end,
];
})
->filter(fn ($event) => $event['start'])
->sortBy('start')
->take($size === 'quadrant' ? 5 : 8)
->values();
@endphp
<x-trmnl::view size="{{$size}}">
<x-trmnl::layout class="layout--col gap--small">
<x-trmnl::table>
<thead>
<tr>
<th>
<x-trmnl::title>Date</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Time</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Event</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Location</x-trmnl::title>
</th>
</tr>
</thead>
<tbody>
@forelse($events as $event)
<tr>
<td>
<x-trmnl::label>{{ $event['start']?->format('D, M j') }}</x-trmnl::label>
</td>
<td>
<x-trmnl::label>
{{ $event['start']?->format('H:i') }}
@if($event['end'])
{{ $event['end']->format('H:i') }}
@endif
</x-trmnl::label>
</td>
<td>
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
</td>
<td>
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label>
</td>
</tr>
@empty
<tr>
<td colspan="4">
<x-trmnl::label>No events available</x-trmnl::label>
</td>
</tr>
@endforelse
</tbody>
</x-trmnl::table>
</x-trmnl::layout>
<x-trmnl::title-bar title="Public Holidays" instance="updated: {{ now()->format('M j, H:i') }}"/>
</x-trmnl::view>

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\Plugin;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
test('plugin parses JSON responses correctly', function (): void {
@ -191,3 +192,96 @@ test('plugin handles POST requests with XML responses', function (): void {
expect($plugin->data_payload['rss']['status'])->toBe('success');
expect($plugin->data_payload['rss']['data'])->toBe('test');
});
test('plugin parses iCal responses and filters to recent window', function (): void {
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:event-1@example.com
DTSTAMP:20250101T120000Z
DTSTART:20250110T090000Z
DTEND:20250110T100000Z
SUMMARY:Past within window
END:VEVENT
BEGIN:VEVENT
UID:event-2@example.com
DTSTAMP:20250101T120000Z
DTSTART:20250301T090000Z
DTEND:20250301T100000Z
SUMMARY:Far future
END:VEVENT
BEGIN:VEVENT
UID:event-3@example.com
DTSTAMP:20250101T120000Z
DTSTART:20250120T090000Z
DTEND:20250120T100000Z
SUMMARY:Upcoming within window
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/calendar.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
$ical = $plugin->data_payload['ical'];
expect($ical)->toHaveCount(2);
expect($ical[0]['SUMMARY'])->toBe('Past within window');
expect($ical[1]['SUMMARY'])->toBe('Upcoming within window');
expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future');
expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00');
expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00');
Carbon::setTestNow();
});
test('plugin detects iCal content without calendar content type', function (): void {
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event-body-detected@example.com
DTSTAMP:20250101T120000Z
DTSTART:20250116T090000Z
DTEND:20250116T100000Z
SUMMARY:Detected by body
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/calendar-body.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
expect($plugin->data_payload)->toHaveKey('ical');
expect($plugin->data_payload['ical'])->toHaveCount(1);
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body');
expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00');
Carbon::setTestNow();
});