Compare commits

..

5 commits

Author SHA1 Message Date
Benjamin Nussbaum
b10bbca774 fix(#124): improve timezone support
Some checks failed
tests / ci (push) Has been cancelled
2025-12-02 16:54:57 +01:00
Benjamin Nussbaum
0322ec899e fix(#123): shared layout not prepended when installing recipe 2025-12-02 15:14:15 +01:00
Benjamin Nussbaum
7c8e55588a fix(#123): normalizes non-named select config options for recipes 2025-12-02 14:58:27 +01:00
Benjamin Nussbaum
dac8064938 fix(#112): error when config field of a recipe expects json 2025-12-02 14:34:46 +01:00
Benjamin Nussbaum
fd41e77e7d chore: update dependencies 2025-12-02 14:29:22 +01:00
12 changed files with 593 additions and 316 deletions

View file

@ -121,6 +121,10 @@ class GenerateDefaultImagesCommand extends Command
$browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html);
// Set timezone from app config (no user context in this command)
$browserStage->timezone(config('app.timezone'));
$browserStage
->width($deviceModel->width)
->height($deviceModel->height);

View file

@ -12,6 +12,7 @@ use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use App\Services\PluginImportService;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -454,6 +455,12 @@ class Plugin extends Model
$renderedContent = '';
if ($this->markup_language === 'liquid') {
// Get timezone from user or fall back to app timezone
$timezone = $this->user->timezone ?? config('app.timezone');
// Calculate UTC offset in seconds
$utcOffset = (string) Carbon::now($timezone)->getOffset();
// Build render context
$context = [
'size' => $size,
@ -465,10 +472,10 @@ class Plugin extends Model
'timestamp_utc' => now()->utc()->timestamp,
],
'user' => [
'utc_offset' => '0',
'utc_offset' => $utcOffset,
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => config('app.timezone'),
'time_zone_iana' => $timezone,
],
'plugin_settings' => [
'instance_name' => $this->name,

View file

@ -27,6 +27,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'assign_new_devices',
'assign_new_device_id',
'oidc_sub',
'timezone',
];
/**

View file

@ -25,7 +25,7 @@ class ImageGenerationService
{
public static function generateImage(string $markup, $deviceId): string
{
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette'])->find($deviceId);
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
$uuid = Uuid::uuid4()->toString();
try {
@ -44,6 +44,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($markup);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage
->width($imageSettings['width'])
@ -352,7 +356,7 @@ class ImageGenerationService
try {
// Load device with relationships
$device->load(['palette', 'deviceModel.palette']);
$device->load(['palette', 'deviceModel.palette', 'user']);
// Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device);
@ -372,6 +376,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage
->width($imageSettings['width'])

View file

@ -80,6 +80,9 @@ class PluginImportService
$settings['custom_fields'] = [];
}
// Normalize options in custom_fields (convert non-named values to named values)
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
// Create configuration template with the custom fields
$configurationTemplate = [
'custom_fields' => $settings['custom_fields'],
@ -206,6 +209,9 @@ class PluginImportService
$settings['custom_fields'] = [];
}
// Normalize options in custom_fields (convert non-named values to named values)
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
// Create configuration template with the custom fields
$configurationTemplate = [
'custom_fields' => $settings['custom_fields'],
@ -343,10 +349,13 @@ class PluginImportService
} elseif ($filename === 'shared.liquid') {
$sharedLiquidPath = $filepath;
}
}
// If we found both required files, break the loop
if ($settingsYamlPath && $fullLiquidPath) {
break;
// Check if shared.liquid exists in the same directory as full.liquid
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) {
$fullLiquidDir = dirname((string) $fullLiquidPath);
if (File::exists($fullLiquidDir.'/shared.liquid')) {
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
}
}
@ -385,6 +394,49 @@ class PluginImportService
];
}
/**
* Normalize options in custom_fields by converting non-named values to named values
* This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]]
*
* @param array $customFields The custom_fields array from settings
* @return array The normalized custom_fields array
*/
private function normalizeCustomFieldsOptions(array $customFields): array
{
foreach ($customFields as &$field) {
// Only process select fields with options
if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) {
$normalizedOptions = [];
foreach ($field['options'] as $option) {
// If option is already a named value (array with key-value pair), keep it as is
if (is_array($option)) {
$normalizedOptions[] = $option;
} else {
// Convert non-named value to named value
// Convert boolean to string, use lowercase for label
$value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option;
$normalizedOptions[] = [$value => $value];
}
}
$field['options'] = $normalizedOptions;
// Normalize default value to match normalized option values
if (isset($field['default'])) {
$default = $field['default'];
// If default is boolean, convert to string to match normalized options
if (is_bool($default)) {
$field['default'] = $default ? 'true' : 'false';
} else {
// Convert to string to ensure consistency
$field['default'] = (string) $default;
}
}
}
}
return $customFields;
}
/**
* Validate that template and context are within command-line argument limits
*

View file

@ -15,7 +15,7 @@
"ext-simplexml": "*",
"ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.0.*",
"bnussbau/trmnl-pipeline-php": "^0.5.0",
"bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2",
"laravel/framework": "^12.1",
"laravel/sanctum": "^4.0",

623
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('timezone')->nullable()->after('oidc_sub');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
};

View file

@ -263,7 +263,19 @@ new class extends Component {
foreach ($this->configuration_template['custom_fields'] as $field) {
$fieldKey = $field['keyname'];
if (isset($this->configuration[$fieldKey])) {
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
$value = $this->configuration[$fieldKey];
// For code fields, if the value is a JSON string and the original was an array, decode it
if ($field['field_type'] === 'code' && is_string($value)) {
$decoded = json_decode($value, true);
// If it's valid JSON and decodes to an array/object, use the decoded value
// Otherwise, keep the string as-is
if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
$value = $decoded;
}
}
$configurationValues[$fieldKey] = $value;
}
}
}
@ -626,7 +638,14 @@ HTML;
@foreach($configuration_template['custom_fields'] as $field)
@php
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
$currentValue = $configuration[$fieldKey] ?? '';
$rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? '');
// For code fields, if the value is an array, JSON encode it
if ($field['field_type'] === 'code' && is_array($rawValue)) {
$currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} else {
$currentValue = is_array($rawValue) ? '' : (string) $rawValue;
}
@endphp
<div class="mb-4">
@if($field['field_type'] === 'author_bio')

View file

@ -11,9 +11,12 @@ use Livewire\Volt\Component;
new class extends Component {
public ?int $assign_new_device_id = null;
public ?string $timezone = null;
public function mount(): void
{
$this->assign_new_device_id = Auth::user()->assign_new_device_id;
$this->timezone = Auth::user()->timezone ?? config('app.timezone');
}
public function updatePreferences(): void
@ -26,6 +29,11 @@ new class extends Component {
->whereNull('mirror_device_id');
}),
],
'timezone' => [
'nullable',
'string',
Rule::in(timezone_identifiers_list()),
],
]);
Auth::user()->update($validated);
@ -39,6 +47,14 @@ new class extends Component {
<x-settings.layout heading="Preferences" subheading="Update your preferences">
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
<flux:select wire:model="timezone" label="Timezone">
<flux:select.option value="" disabled>Select timezone...</flux:select.option>
@foreach(timezone_identifiers_list() as $tz)
<flux:select.option value="{{ $tz }}">{{ $tz }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror">
<flux:select.option value="">None</flux:select.option>
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)

View file

@ -388,6 +388,45 @@ it('does not set icon_url when importing from URL without iconUrl parameter', fu
->and($plugin->icon_url)->toBeNull();
});
it('normalizes non-named select options to named values', function (): void {
$user = User::factory()->create();
$settingsYaml = <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{}'
custom_fields:
- keyname: display_incident
field_type: select
options:
- true
- false
default: true
YAML;
$zipContent = createMockZipFile([
'src/settings.yml' => $settingsYaml,
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
$customFields = $plugin->configuration_template['custom_fields'];
$displayIncidentField = collect($customFields)->firstWhere('keyname', 'display_incident');
expect($displayIncidentField)->not->toBeNull()
->and($displayIncidentField['options'])->toBe([
['true' => 'true'],
['false' => 'false'],
])
->and($displayIncidentField['default'])->toBe('true');
});
// Helper methods
function createMockZipFile(array $files): string
{

View file

@ -1,6 +1,8 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -587,3 +589,93 @@ LIQUID
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('plugin render uses user timezone when set', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York',
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('America/New_York');
});
test('plugin render falls back to app timezone when user timezone is not set', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('Europe/London');
});
test('plugin render calculates correct UTC offset from user timezone', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
$expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
$expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
$user = User::factory()->create([
'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
expect($rendered)
->toContain('America/Chicago')
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
});