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')) {
$parsers = app(ResponseParserRegistry::class)->getParsers();
foreach ($parsers as $parser) {
$parserName = class_basename($parser);
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');
$result = $parser->parse($httpResponse);
if ($result !== null) {
return $result;
}
// 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'];
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
}
}
try {
// Attempt to parse it into JSON
$json = $httpResponse->json();
if ($json !== null) {
return $json;
}
// Response doesn't seem to be JSON, wrap the response body text as a JSON object
return ['data' => $httpResponse->body()];
} catch (Exception $e) {
Log::warning('Failed to parse JSON response: '.$e->getMessage());
return ['error' => 'Failed to parse JSON response'];
}
}
/**
* 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;
}
}