mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
2 commits
838db288e7
...
b6faa2f232
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6faa2f232 | ||
|
|
60f2a38169 |
14 changed files with 548 additions and 77 deletions
|
|
@ -131,6 +131,6 @@ class Data extends FiltersProvider
|
||||||
*/
|
*/
|
||||||
public function map_to_i(array $input): array
|
public function map_to_i(array $input): array
|
||||||
{
|
{
|
||||||
return array_map('intval', $input);
|
return array_map(intval(...), $input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters;
|
||||||
use App\Liquid\Filters\StringMarkup;
|
use App\Liquid\Filters\StringMarkup;
|
||||||
use App\Liquid\Filters\Uniqueness;
|
use App\Liquid\Filters\Uniqueness;
|
||||||
use App\Liquid\Tags\TemplateTag;
|
use App\Liquid\Tags\TemplateTag;
|
||||||
|
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -26,7 +27,6 @@ use Illuminate\Support\Str;
|
||||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||||
use SimpleXMLElement;
|
|
||||||
|
|
||||||
class Plugin extends Model
|
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
|
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 {
|
try {
|
||||||
// Convert XML to array and wrap under 'rss' key
|
$result = $parser->parse($httpResponse);
|
||||||
$xml = simplexml_load_string($httpResponse->body());
|
|
||||||
if ($xml === false) {
|
if ($result !== null) {
|
||||||
throw new Exception('Invalid XML content');
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert SimpleXML directly to array
|
|
||||||
$xmlArray = $this->xmlToArray($xml);
|
|
||||||
|
|
||||||
return ['rss' => $xmlArray];
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::warning('Failed to parse XML response: '.$e->getMessage());
|
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||||
|
|
||||||
return ['error' => 'Failed to parse XML response'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return ['error' => 'Failed to parse response'];
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal 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;
|
||||||
|
}
|
||||||
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/volt": "^1.7",
|
"livewire/volt": "^1.7",
|
||||||
|
"om/icalparser": "^3.2",
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
"symfony/yaml": "^7.3",
|
"symfony/yaml": "^7.3",
|
||||||
"wnx/sidecar-browsershot": "^2.6"
|
"wnx/sidecar-browsershot": "^2.6"
|
||||||
|
|
|
||||||
53
composer.lock
generated
53
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7750ff686c4cad7f85390488c28b33ca",
|
"content-hash": "3e4c22c016c04e49512b5fcd20983baa",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -3710,6 +3710,57 @@
|
||||||
],
|
],
|
||||||
"time": "2025-11-20T02:34:59+00:00"
|
"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",
|
"name": "paragonie/constant_time_encoding",
|
||||||
"version": "v3.1.3",
|
"version": "v3.1.3",
|
||||||
|
|
|
||||||
|
|
@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
'flux_icon_name' => 'flower',
|
'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',
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
package-lock.json
generated
61
package-lock.json
generated
|
|
@ -23,7 +23,7 @@
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"puppeteer": "24.17.0",
|
"puppeteer": "24.30.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
|
@ -772,17 +772,17 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@puppeteer/browsers": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.13",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz",
|
||||||
"integrity": "sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==",
|
"integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.3",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"progress": "^2.0.3",
|
"progress": "^2.0.3",
|
||||||
"proxy-agent": "^6.5.0",
|
"proxy-agent": "^6.5.0",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.3",
|
||||||
"tar-fs": "^3.1.0",
|
"tar-fs": "^3.1.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -1709,9 +1709,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chromium-bidi": {
|
"node_modules/chromium-bidi": {
|
||||||
"version": "8.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz",
|
||||||
"integrity": "sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==",
|
"integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
|
|
@ -1895,9 +1895,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devtools-protocol": {
|
"node_modules/devtools-protocol": {
|
||||||
"version": "0.0.1475386",
|
"version": "0.0.1521046",
|
||||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
|
||||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
|
@ -3039,17 +3039,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/puppeteer": {
|
"node_modules/puppeteer": {
|
||||||
"version": "24.17.0",
|
"version": "24.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz",
|
||||||
"integrity": "sha512-CGrmJ8WgilK3nyE73k+pbxHggETPpEvL6AQ9H5JSK1RgZRGMQVJ+iO3MocGm9yBQXQJ9U5xijyLvkYXFeb0/+g==",
|
"integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@puppeteer/browsers": "2.10.7",
|
"@puppeteer/browsers": "2.10.13",
|
||||||
"chromium-bidi": "8.0.0",
|
"chromium-bidi": "11.0.0",
|
||||||
"cosmiconfig": "^9.0.0",
|
"cosmiconfig": "^9.0.0",
|
||||||
"devtools-protocol": "0.0.1475386",
|
"devtools-protocol": "0.0.1521046",
|
||||||
"puppeteer-core": "24.17.0",
|
"puppeteer-core": "24.30.0",
|
||||||
"typed-query-selector": "^2.12.0"
|
"typed-query-selector": "^2.12.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3060,16 +3060,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/puppeteer-core": {
|
"node_modules/puppeteer-core": {
|
||||||
"version": "24.17.0",
|
"version": "24.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz",
|
||||||
"integrity": "sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==",
|
"integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@puppeteer/browsers": "2.10.7",
|
"@puppeteer/browsers": "2.10.13",
|
||||||
"chromium-bidi": "8.0.0",
|
"chromium-bidi": "11.0.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.3",
|
||||||
"devtools-protocol": "0.0.1475386",
|
"devtools-protocol": "0.0.1521046",
|
||||||
"typed-query-selector": "^2.12.0",
|
"typed-query-selector": "^2.12.0",
|
||||||
|
"webdriver-bidi-protocol": "0.3.8",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3526,6 +3527,12 @@
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/webdriver-bidi-protocol": {
|
||||||
|
"version": "0.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz",
|
||||||
|
"integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"puppeteer": "24.17.0",
|
"puppeteer": "24.30.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
87
resources/views/recipes/holidays-ical.blade.php
Normal file
87
resources/views/recipes/holidays-ical.blade.php
Normal 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>
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
test('plugin parses JSON responses correctly', function (): void {
|
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']['status'])->toBe('success');
|
||||||
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
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();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue