Compare commits
No commits in common. "main" and "0.26.0" have entirely different histories.
2
.github/FUNDING.yml
vendored
|
|
@ -1 +1 @@
|
||||||
custom: ["https://trmnl.com/?ref=laravel-trmnl"]
|
custom: ["https://usetrmnl.com/?ref=laravel-trmnl"]
|
||||||
|
|
|
||||||
5
.github/workflows/docker-build.yml
vendored
|
|
@ -6,6 +6,7 @@ on:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|
@ -39,9 +40,7 @@ jobs:
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
${{ env.REGISTRY }}/usetrmnl/byos_laravel
|
|
||||||
${{ env.REGISTRY }}/usetrmnl/larapaper
|
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
|
|
||||||
5
.gitignore
vendored
|
|
@ -37,8 +37,3 @@ yarn-error.log
|
||||||
/.claude
|
/.claude
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
/opencode.json
|
/opencode.json
|
||||||
/.cursor
|
|
||||||
/.opencode
|
|
||||||
/build.sh
|
|
||||||
/.junie
|
|
||||||
/.agents
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
########################
|
########################
|
||||||
# Base Image
|
# Base Image
|
||||||
########################
|
########################
|
||||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base
|
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/larapaper
|
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||||
LABEL org.opencontainers.image.description="LaraPaper"
|
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||||
LABEL org.opencontainers.image.licenses=MIT
|
LABEL org.opencontainers.image.licenses=MIT
|
||||||
|
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
|
|
@ -18,7 +18,7 @@ ENV TRMNL_LIQUID_ENABLED=1
|
||||||
# Switch to the root user so we can do root things
|
# Switch to the root user so we can do root things
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.2.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
|
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
|
||||||
40
README.md
|
|
@ -1,9 +1,9 @@
|
||||||
## LaraPaper (PHP/Laravel)
|
## TRMNL BYOS (PHP/Laravel)
|
||||||
|
|
||||||
[](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml)
|
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||||
|
|
||||||
LaraPaper is a self-hostable implementation of a TRMNL server (BYOS), built with Laravel.
|
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||||
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ stars, it’s the most popular community-driven BYOS.
|
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
@ -15,9 +15,9 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
|
||||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||||
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
||||||
* Support for TRMNL [Design Framework](https://trmnl.com/framework)
|
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
|
||||||
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
||||||
* Import from the [TRMNL community recipe catalog](https://trmnl.com/recipes)
|
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
|
||||||
* Supported Devices
|
* Supported Devices
|
||||||
* TRMNL OG (1-bit & 2-bit)
|
* TRMNL OG (1-bit & 2-bit)
|
||||||
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
||||||
|
|
@ -26,7 +26,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
|
||||||
* Custom ESP32 with TRMNL firmware
|
* Custom ESP32 with TRMNL firmware
|
||||||
* E-Reader Devices
|
* E-Reader Devices
|
||||||
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
|
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
|
||||||
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/larapaper/pull/27))
|
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
|
||||||
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
|
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
|
||||||
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
|
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
|
||||||
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
|
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
|
||||||
|
|
@ -43,7 +43,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
|
||||||
### Support ❤️
|
### Support ❤️
|
||||||
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
||||||
|
|
||||||
Support the development of this package by purchasing a TRMNL device through the referral link: https://trmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/doc
|
||||||
|
|
||||||
##### Backup Database
|
##### Backup Database
|
||||||
```sh
|
```sh
|
||||||
docker ps #find container id of larapaper container
|
docker ps #find container id of byos_laravel container
|
||||||
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
|
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -73,11 +73,11 @@ docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### VPS
|
#### VPS
|
||||||
If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy LaraPaper using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
|
If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
|
||||||
It’s a quick way to get started without having to manually manage Docker setup.
|
It’s a quick way to get started without having to manually manage Docker setup.
|
||||||
|
|
||||||
#### PikaPods
|
#### PikaPods
|
||||||
You can vote for LaraPaper to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
|
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
|
||||||
|
|
||||||
#### Umbrel
|
#### Umbrel
|
||||||
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
|
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
|
||||||
|
|
@ -121,16 +121,10 @@ php artisan db:seed --class=ExampleRecipesSeeder
|
||||||
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
|
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
|
||||||
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
||||||
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
|
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
|
||||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. Alternative: `TRUSTED_PROXIES`. | 0 |
|
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
||||||
| `TRUSTED_PROXIES` | If your server handles SSL termination, allow mixed mode. e.g. `"172.0.0.0/8"` or `*` | null |
|
|
||||||
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
|
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
|
||||||
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
|
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
|
||||||
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions. UTC is recommended. | UTC |
|
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
|
||||||
|
|
||||||
##### Experimental Environment Variables
|
|
||||||
| Environment Variable | Description | Default |
|
|
||||||
|----------------------------------|--------------------------------------------------------------------------------|---------|
|
|
||||||
| `PUPPETEER_WINDOW_SIZE_STRATEGY` | Set to `v2` to size the browser window to match the device’s screen dimensions | `null` |
|
|
||||||
|
|
||||||
#### Login
|
#### Login
|
||||||
|
|
||||||
|
|
@ -173,13 +167,13 @@ See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://ww
|
||||||
### ☁️ Activate fresh TRMNL Device with Cloud Proxy
|
### ☁️ Activate fresh TRMNL Device with Cloud Proxy
|
||||||
|
|
||||||
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
|
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
|
||||||
2) Setup LaraPaper, create a user and login
|
2) Setup Laravel BYOS, create a user and login
|
||||||
3) In LaraPaper in the header bar, activate the toggle "Permit Auto-Join"
|
3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join"
|
||||||
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
|
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
|
||||||
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of LaraPaper.
|
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS
|
||||||
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
|
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
|
||||||
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
|
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
|
||||||
8) As long as no LaraPaper plugin is scheduled, the device will show your cloud plugins.
|
8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins.
|
||||||
|
|
||||||
###### Troubleshooting
|
###### Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ class GenerateDefaultImagesCommand extends Command
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine device properties from DeviceModel
|
// Determine device properties from DeviceModel
|
||||||
$deviceVariant = $deviceModel->css_name ?? $deviceModel->name ?? 'og';
|
$deviceVariant = $deviceModel->name ?? 'og';
|
||||||
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
|
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
|
||||||
$scaleLevel = $deviceModel->scale_level; // Use the accessor method
|
$scaleLevel = $deviceModel->scale_level; // Use the accessor method
|
||||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||||
|
|
@ -196,7 +196,6 @@ class GenerateDefaultImagesCommand extends Command
|
||||||
'deviceVariant' => $deviceVariant,
|
'deviceVariant' => $deviceVariant,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
'cssVariables' => $deviceModel->css_variables,
|
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class CheckVersionUpdateJob
|
||||||
|
|
||||||
private function extractLatestVersion(array $response, bool $enablePrereleases): array
|
private function extractLatestVersion(array $response, bool $enablePrereleases): array
|
||||||
{
|
{
|
||||||
if (! $enablePrereleases || ! isset($response[0])) {
|
if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) {
|
||||||
return [
|
return [
|
||||||
Arr::get($response, 'tag_name'),
|
Arr::get($response, 'tag_name'),
|
||||||
$response,
|
$response,
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,14 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final class FetchDeviceModelsJob implements ShouldQueue
|
final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
private const API_URL = '/api/models';
|
private const API_URL = 'https://usetrmnl.com/api/models';
|
||||||
|
|
||||||
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||||
|
|
||||||
|
|
@ -41,7 +39,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
try {
|
try {
|
||||||
$this->processPalettes();
|
$this->processPalettes();
|
||||||
|
|
||||||
$response = Http::timeout(30)->get(config('services.trmnl.base_url').self::API_URL);
|
$response = Http::timeout(30)->get(self::API_URL);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('Failed to fetch device models from API', [
|
Log::error('Failed to fetch device models from API', [
|
||||||
|
|
@ -211,41 +209,12 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
$attributes['palette_id'] = $firstPaletteId;
|
$attributes['palette_id'] = $firstPaletteId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes['css_name'] = $this->parseCssNameFromApi($modelData['css'] ?? null);
|
|
||||||
$attributes['css_variables'] = $this->parseCssVariablesFromApi($modelData['css'] ?? null);
|
|
||||||
|
|
||||||
DeviceModel::updateOrCreate(
|
DeviceModel::updateOrCreate(
|
||||||
['name' => $name],
|
['name' => $name],
|
||||||
$attributes
|
$attributes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract css_name from API css payload (strip "screen--" prefix from classes.device).
|
|
||||||
*/
|
|
||||||
private function parseCssNameFromApi(mixed $css): ?string
|
|
||||||
{
|
|
||||||
$deviceClass = is_array($css) ? Arr::get($css, 'classes.device') : null;
|
|
||||||
|
|
||||||
return (is_string($deviceClass) ? Str::after($deviceClass, 'screen--') : null) ?: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract css_variables from API css payload (convert [[key, value], ...] to associative array).
|
|
||||||
*/
|
|
||||||
private function parseCssVariablesFromApi(mixed $css): ?array
|
|
||||||
{
|
|
||||||
$pairs = is_array($css) ? Arr::get($css, 'variables', []) : [];
|
|
||||||
if (! is_array($pairs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$validPairs = Arr::where($pairs, fn (mixed $pair): bool => is_array($pair) && isset($pair[0], $pair[1]));
|
|
||||||
$variables = Arr::pluck($validPairs, 1, 0);
|
|
||||||
|
|
||||||
return $variables !== [] ? $variables : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the first palette ID from model data.
|
* Get the first palette ID from model data.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ class FirmwarePollJob implements ShouldQueue
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$firmwareEndpoint = config('services.trmnl.base_url').'/api/firmware/latest';
|
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json();
|
||||||
|
|
||||||
$response = Http::get($firmwareEndpoint)->json();
|
|
||||||
|
|
||||||
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
||||||
Log::error('Invalid firmware response format received');
|
Log::error('Invalid firmware response format received');
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,8 @@ class GenerateScreenJob implements ShouldQueue
|
||||||
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
||||||
|
|
||||||
if ($this->pluginId) {
|
if ($this->pluginId) {
|
||||||
$plugin = Plugin::find($this->pluginId);
|
// cache current image
|
||||||
$update = ['current_image' => $newImageUuid];
|
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
|
||||||
if ($plugin->plugin_type === 'recipe') {
|
|
||||||
$device = Device::with(['deviceModel', 'deviceModel.palette'])->find($this->deviceId);
|
|
||||||
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDevice($device);
|
|
||||||
}
|
|
||||||
$plugin->update($update);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageGenerationService::cleanupFolder();
|
ImageGenerationService::cleanupFolder();
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Liquid\Tags;
|
|
||||||
|
|
||||||
use Keepsuit\Liquid\Render\RenderContext;
|
|
||||||
use Keepsuit\Liquid\Support\MissingValue;
|
|
||||||
use Keepsuit\Liquid\Tags\RenderTag;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render tag that injects plugin context (trmnl, size, data, config) into partials
|
|
||||||
* so shared templates can use variables like trmnl.user.name without passing them explicitly.
|
|
||||||
*/
|
|
||||||
class PluginRenderTag extends RenderTag
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Root-level keys from the plugin render context that should be available in partials.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const PARENT_CONTEXT_KEYS = ['trmnl', 'size', 'data', 'config'];
|
|
||||||
|
|
||||||
protected function buildPartialContext(RenderContext $rootContext, string $templateName, array $variables = []): RenderContext
|
|
||||||
{
|
|
||||||
$partialContext = $rootContext->newIsolatedSubContext($templateName);
|
|
||||||
|
|
||||||
foreach (self::PARENT_CONTEXT_KEYS as $key) {
|
|
||||||
$value = $rootContext->get($key);
|
|
||||||
if ($value !== null && ! $value instanceof MissingValue) {
|
|
||||||
$partialContext->set($key, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($variables as $key => $value) {
|
|
||||||
$partialContext->set($key, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->attributes as $key => $value) {
|
|
||||||
$partialContext->set($key, $rootContext->evaluate($value));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $partialContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,6 @@ class Device extends Model
|
||||||
'sleep_mode_to' => 'datetime:H:i',
|
'sleep_mode_to' => 'datetime:H:i',
|
||||||
'special_function' => 'string',
|
'special_function' => 'string',
|
||||||
'pause_until' => 'datetime',
|
'pause_until' => 'datetime',
|
||||||
'maximum_compatibility' => 'boolean',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getBatteryPercentAttribute(): int|float
|
public function getBatteryPercentAttribute(): int|float
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,11 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property-read array<string, string> $css_variables
|
|
||||||
* @property-read string|null $css_name
|
|
||||||
* @property-read DevicePalette|null $palette
|
* @property-read DevicePalette|null $palette
|
||||||
*/
|
*/
|
||||||
final class DeviceModel extends Model
|
final class DeviceModel extends Model
|
||||||
|
|
@ -30,7 +27,6 @@ final class DeviceModel extends Model
|
||||||
'offset_x' => 'integer',
|
'offset_x' => 'integer',
|
||||||
'offset_y' => 'integer',
|
'offset_y' => 'integer',
|
||||||
'published_at' => 'datetime',
|
'published_at' => 'datetime',
|
||||||
'css_variables' => 'array',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getColorDepthAttribute(): ?string
|
public function getColorDepthAttribute(): ?string
|
||||||
|
|
@ -75,54 +71,8 @@ final class DeviceModel extends Model
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns css_name for v2 (per-device sizing); for v1 returns 'og' to preserve legacy single-variant behaviour.
|
|
||||||
*
|
|
||||||
* @return Attribute<string|null, string|null>
|
|
||||||
*/
|
|
||||||
protected function cssName(): Attribute
|
|
||||||
{
|
|
||||||
/** @var Attribute<string|null, string|null> */
|
|
||||||
return Attribute::get(
|
|
||||||
fn (mixed $value): ?string => config('app.puppeteer_window_size_strategy') === 'v2' ? ($value !== null ? (string) $value : null) : 'og'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function palette(): BelongsTo
|
public function palette(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns css_variables with --screen-w and --screen-h filled from width/height
|
|
||||||
* when puppeteer_window_size_strategy is v2 and they are not set.
|
|
||||||
*
|
|
||||||
* @return Attribute<array<string, string>, array<string, string>>
|
|
||||||
*/
|
|
||||||
protected function cssVariables(): Attribute
|
|
||||||
{
|
|
||||||
/** @var Attribute<array<string, string>, array<string, string>> */
|
|
||||||
return Attribute::get(
|
|
||||||
/** @return array<string, string> */
|
|
||||||
function (mixed $value, array $attributes): array {
|
|
||||||
$vars = is_array($value) ? $value : (is_string($value) ? (json_decode($value, true) ?? []) : []);
|
|
||||||
|
|
||||||
if (config('app.puppeteer_window_size_strategy') !== 'v2') {
|
|
||||||
return $vars;
|
|
||||||
}
|
|
||||||
|
|
||||||
$width = $attributes['width'] ?? null;
|
|
||||||
$height = $attributes['height'] ?? null;
|
|
||||||
|
|
||||||
if (empty($vars['--screen-w']) && $width !== null && $width !== '') {
|
|
||||||
$vars['--screen-w'] = is_numeric($width) ? (int) $width.'px' : (string) $width;
|
|
||||||
}
|
|
||||||
if (empty($vars['--screen-h']) && $height !== null && $height !== '') {
|
|
||||||
$vars['--screen-h'] = is_numeric($height) ? (int) $height.'px' : (string) $height;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, string> $vars */
|
|
||||||
return $vars;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,10 @@ class PlaylistItem extends Model
|
||||||
if (! $this->isMashup()) {
|
if (! $this->isMashup()) {
|
||||||
return view('trmnl-layouts.single', [
|
return view('trmnl-layouts.single', [
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'slot' => $this->plugin instanceof Plugin
|
'slot' => $this->plugin instanceof Plugin
|
||||||
? $this->plugin->render('full', false, $device)
|
? $this->plugin->render('full', false)
|
||||||
: throw new Exception('Invalid plugin instance'),
|
: throw new Exception('Invalid plugin instance'),
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -158,14 +157,13 @@ class PlaylistItem extends Model
|
||||||
|
|
||||||
foreach ($plugins as $index => $plugin) {
|
foreach ($plugins as $index => $plugin) {
|
||||||
$size = $this->getLayoutSize($index);
|
$size = $this->getLayoutSize($index);
|
||||||
$pluginMarkups[] = $plugin->render($size, false, $device);
|
$pluginMarkups[] = $plugin->render($size, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('trmnl-layouts.mashup', [
|
return view('trmnl-layouts.mashup', [
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'mashupLayout' => $this->getMashupLayoutType(),
|
'mashupLayout' => $this->getMashupLayoutType(),
|
||||||
'slot' => implode('', $pluginMarkups),
|
'slot' => implode('', $pluginMarkups),
|
||||||
])->render();
|
])->render();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use App\Liquid\Filters\Numbers;
|
||||||
use App\Liquid\Filters\StandardFilters;
|
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\PluginRenderTag;
|
|
||||||
use App\Liquid\Tags\TemplateTag;
|
use App\Liquid\Tags\TemplateTag;
|
||||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
|
|
@ -29,7 +28,6 @@ use InvalidArgumentException;
|
||||||
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 Symfony\Component\Yaml\Yaml;
|
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -49,7 +47,6 @@ class Plugin extends Model
|
||||||
'preferred_renderer' => 'string',
|
'preferred_renderer' => 'string',
|
||||||
'plugin_type' => 'string',
|
'plugin_type' => 'string',
|
||||||
'alias' => 'boolean',
|
'alias' => 'boolean',
|
||||||
'current_image_metadata' => 'array',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
|
|
@ -63,16 +60,9 @@ class Plugin extends Model
|
||||||
});
|
});
|
||||||
|
|
||||||
static::updating(function ($model): void {
|
static::updating(function ($model): void {
|
||||||
// Reset image cache when any markup changes
|
// Reset image cache when markup changes
|
||||||
if ($model->isDirty([
|
if ($model->isDirty('render_markup')) {
|
||||||
'render_markup',
|
|
||||||
'render_markup_half_horizontal',
|
|
||||||
'render_markup_half_vertical',
|
|
||||||
'render_markup_quadrant',
|
|
||||||
'render_markup_shared',
|
|
||||||
])) {
|
|
||||||
$model->current_image = null;
|
$model->current_image = null;
|
||||||
$model->current_image_metadata = null;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -82,68 +72,11 @@ class Plugin extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public const CUSTOM_FIELDS_KEY = 'custom_fields';
|
|
||||||
|
|
||||||
public function user()
|
public function user()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* YAML for the custom_fields editor
|
|
||||||
*/
|
|
||||||
public function getCustomFieldsEditorYaml(): string
|
|
||||||
{
|
|
||||||
$template = $this->configuration_template;
|
|
||||||
$list = $template[self::CUSTOM_FIELDS_KEY] ?? null;
|
|
||||||
if ($list === null || $list === []) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return Yaml::dump($list, 4, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse editor YAML and return configuration_template for DB (custom_fields key). Returns null when empty.
|
|
||||||
*/
|
|
||||||
public static function configurationTemplateFromCustomFieldsYaml(string $yaml, ?array $existingTemplate): ?array
|
|
||||||
{
|
|
||||||
$list = $yaml !== '' ? Yaml::parse($yaml) : [];
|
|
||||||
if ($list === null || (is_array($list) && $list === [])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$template = $existingTemplate ?? [];
|
|
||||||
$template[self::CUSTOM_FIELDS_KEY] = is_array($list) ? $list : [];
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that each custom field entry has field_type and name. For use with parsed editor YAML.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $list
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public static function validateCustomFieldsList(array $list): void
|
|
||||||
{
|
|
||||||
$validator = \Illuminate\Support\Facades\Validator::make(
|
|
||||||
['custom_fields' => $list],
|
|
||||||
[
|
|
||||||
'custom_fields' => ['required', 'array'],
|
|
||||||
'custom_fields.*.field_type' => ['required', 'string'],
|
|
||||||
'custom_fields.*.name' => ['required', 'string'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'custom_fields.*.field_type.required' => 'Each custom field must have a field_type.',
|
|
||||||
'custom_fields.*.name.required' => 'Each custom field must have a name.',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$validator->validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
||||||
protected function sanitizeTemplate(): void
|
protected function sanitizeTemplate(): void
|
||||||
{
|
{
|
||||||
|
|
@ -224,7 +157,7 @@ class Plugin extends Model
|
||||||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$headers = ['User-Agent' => 'usetrmnl/larapaper', 'Accept' => 'application/json'];
|
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||||
|
|
||||||
// resolve headers
|
// resolve headers
|
||||||
if ($this->polling_header) {
|
if ($this->polling_header) {
|
||||||
|
|
@ -252,12 +185,8 @@ class Plugin extends Model
|
||||||
$httpRequest = Http::withHeaders($headers);
|
$httpRequest = Http::withHeaders($headers);
|
||||||
|
|
||||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||||
$contentType = (array_key_exists('Content-Type', $headers))
|
|
||||||
? $headers['Content-Type']
|
|
||||||
: 'application/json';
|
|
||||||
|
|
||||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||||
$httpRequest = $httpRequest->withBody($resolvedBody, $contentType);
|
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -492,9 +421,7 @@ class Plugin extends Model
|
||||||
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$markup = $this->getMarkupForSize($size);
|
if ($this->render_markup) {
|
||||||
|
|
||||||
if ($markup) {
|
|
||||||
$renderedContent = '';
|
$renderedContent = '';
|
||||||
|
|
||||||
if ($this->markup_language === 'liquid') {
|
if ($this->markup_language === 'liquid') {
|
||||||
|
|
@ -520,13 +447,6 @@ class Plugin extends Model
|
||||||
'locale' => 'en',
|
'locale' => 'en',
|
||||||
'time_zone_iana' => $timezone,
|
'time_zone_iana' => $timezone,
|
||||||
],
|
],
|
||||||
'device' => [
|
|
||||||
'friendly_id' => $device?->friendly_id,
|
|
||||||
'percent_charged' => $device?->battery_percent,
|
|
||||||
'wifi_strength' => $device?->wifi_strength,
|
|
||||||
'height' => $device?->height,
|
|
||||||
'width' => $device?->width,
|
|
||||||
],
|
|
||||||
'plugin_settings' => [
|
'plugin_settings' => [
|
||||||
'instance_name' => $this->name,
|
'instance_name' => $this->name,
|
||||||
'strategy' => $this->data_strategy,
|
'strategy' => $this->data_strategy,
|
||||||
|
|
@ -544,7 +464,7 @@ class Plugin extends Model
|
||||||
// Check if external renderer should be used
|
// Check if external renderer should be used
|
||||||
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
||||||
// Use external Ruby renderer - pass raw template without preprocessing
|
// Use external Ruby renderer - pass raw template without preprocessing
|
||||||
$renderedContent = $this->renderWithExternalLiquidRenderer($markup, $context);
|
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
||||||
} else {
|
} else {
|
||||||
// Use PHP keepsuit/liquid renderer
|
// Use PHP keepsuit/liquid renderer
|
||||||
// Create a custom environment with inline templates support
|
// Create a custom environment with inline templates support
|
||||||
|
|
@ -564,56 +484,19 @@ class Plugin extends Model
|
||||||
|
|
||||||
// Register the template tag for inline templates
|
// Register the template tag for inline templates
|
||||||
$environment->tagRegistry->register(TemplateTag::class);
|
$environment->tagRegistry->register(TemplateTag::class);
|
||||||
// Use plugin render tag so partials receive trmnl, size, data, config
|
|
||||||
$environment->tagRegistry->register(PluginRenderTag::class);
|
|
||||||
|
|
||||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||||
$processedMarkup = $this->applyLiquidReplacements($markup);
|
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||||
|
|
||||||
$template = $environment->parseString($processedMarkup);
|
$template = $environment->parseString($processedMarkup);
|
||||||
$liquidContext = $environment->newRenderContext(data: $context);
|
$liquidContext = $environment->newRenderContext(data: $context);
|
||||||
$renderedContent = $template->render($liquidContext);
|
$renderedContent = $template->render($liquidContext);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get timezone from user or fall back to app timezone
|
$renderedContent = Blade::render($this->render_markup, [
|
||||||
$timezone = $this->user->timezone ?? config('app.timezone');
|
|
||||||
|
|
||||||
// Calculate UTC offset in seconds
|
|
||||||
$utcOffset = (string) Carbon::now($timezone)->getOffset();
|
|
||||||
|
|
||||||
$renderedContent = Blade::render($markup, [
|
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'data' => $this->data_payload,
|
'data' => $this->data_payload,
|
||||||
'config' => $this->configuration ?? [],
|
'config' => $this->configuration ?? [],
|
||||||
'trmnl' => [
|
|
||||||
'system' => [
|
|
||||||
'timestamp_utc' => now()->utc()->timestamp,
|
|
||||||
],
|
|
||||||
'user' => [
|
|
||||||
'utc_offset' => $utcOffset,
|
|
||||||
'name' => $this->user->name ?? 'Unknown User',
|
|
||||||
'locale' => 'en',
|
|
||||||
'time_zone_iana' => $timezone,
|
|
||||||
],
|
|
||||||
'device' => [
|
|
||||||
'friendly_id' => $device?->friendly_id,
|
|
||||||
'percent_charged' => $device?->battery_percent,
|
|
||||||
'wifi_strength' => $device?->wifi_strength,
|
|
||||||
'height' => $device?->height,
|
|
||||||
'width' => $device?->width,
|
|
||||||
],
|
|
||||||
'plugin_settings' => [
|
|
||||||
'instance_name' => $this->name,
|
|
||||||
'strategy' => $this->data_strategy,
|
|
||||||
'dark_mode' => $this->dark_mode ? 'yes' : 'no',
|
|
||||||
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
|
|
||||||
'polling_headers' => $this->polling_header,
|
|
||||||
'polling_url' => $this->polling_url,
|
|
||||||
'custom_fields_values' => [
|
|
||||||
...(is_array($this->configuration) ? $this->configuration : []),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,11 +504,10 @@ class Plugin extends Model
|
||||||
if ($size === 'full') {
|
if ($size === 'full') {
|
||||||
return view('trmnl-layouts.single', [
|
return view('trmnl-layouts.single', [
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'noBleed' => $this->no_bleed,
|
'noBleed' => $this->no_bleed,
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'slot' => $renderedContent,
|
'slot' => $renderedContent,
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -633,10 +515,9 @@ class Plugin extends Model
|
||||||
return view('trmnl-layouts.mashup', [
|
return view('trmnl-layouts.mashup', [
|
||||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'slot' => $renderedContent,
|
'slot' => $renderedContent,
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
|
|
@ -656,11 +537,10 @@ class Plugin extends Model
|
||||||
if ($size === 'full') {
|
if ($size === 'full') {
|
||||||
return view('trmnl-layouts.single', [
|
return view('trmnl-layouts.single', [
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'noBleed' => $this->no_bleed,
|
'noBleed' => $this->no_bleed,
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'slot' => $renderedView,
|
'slot' => $renderedView,
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -668,10 +548,9 @@ class Plugin extends Model
|
||||||
return view('trmnl-layouts.mashup', [
|
return view('trmnl-layouts.mashup', [
|
||||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
|
||||||
'slot' => $renderedView,
|
'slot' => $renderedView,
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -695,30 +574,6 @@ class Plugin extends Model
|
||||||
return $this->configuration[$key] ?? $default;
|
return $this->configuration[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the appropriate markup for a given size, including shared prepending logic
|
|
||||||
*
|
|
||||||
* @param string $size The layout size (full, half_horizontal, half_vertical, quadrant)
|
|
||||||
* @return string|null The markup code for the given size, with shared prepended if available
|
|
||||||
*/
|
|
||||||
public function getMarkupForSize(string $size): ?string
|
|
||||||
{
|
|
||||||
$markup = match ($size) {
|
|
||||||
'full' => $this->render_markup,
|
|
||||||
'half_horizontal' => $this->render_markup_half_horizontal ?? $this->render_markup,
|
|
||||||
'half_vertical' => $this->render_markup_half_vertical ?? $this->render_markup,
|
|
||||||
'quadrant' => $this->render_markup_quadrant ?? $this->render_markup,
|
|
||||||
default => $this->render_markup,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepend shared markup if it exists
|
|
||||||
if ($markup && $this->render_markup_shared) {
|
|
||||||
$markup = $this->render_markup_shared."\n".$markup;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $markup;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPreviewMashupLayoutForSize(string $size): string
|
public function getPreviewMashupLayoutForSize(string $size): string
|
||||||
{
|
{
|
||||||
return match ($size) {
|
return match ($size) {
|
||||||
|
|
@ -775,8 +630,8 @@ class Plugin extends Model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append "_copy" to the name
|
// Append " (Copy)" to the name
|
||||||
$attributes['name'] = $this->name.'_copy';
|
$attributes['name'] = $this->name.' (Copy)';
|
||||||
|
|
||||||
// Set user_id - use provided userId or fall back to original plugin's user_id
|
// Set user_id - use provided userId or fall back to original plugin's user_id
|
||||||
$attributes['user_id'] = $userId ?? $this->user_id;
|
$attributes['user_id'] = $userId ?? $this->user_id;
|
||||||
|
|
|
||||||
|
|
@ -331,88 +331,36 @@ class ImageGenerationService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||||
* Ensure plugin image cache is valid for the current context. No-op for image_webhook.
|
|
||||||
* When deviceOrModel is provided (recipe only), clears cache if stored metadata does not match.
|
|
||||||
*/
|
|
||||||
public static function resetIfNotCacheable(?Plugin $plugin, Device|DeviceModel|null $deviceOrModel = null): void
|
|
||||||
{
|
{
|
||||||
if (! $plugin?->id || $plugin->plugin_type === 'image_webhook') {
|
if ($plugin?->id) {
|
||||||
|
// Image webhook plugins have finalized images that shouldn't be reset
|
||||||
|
if ($plugin->plugin_type === 'image_webhook') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($deviceOrModel === null || $plugin->plugin_type !== 'recipe') {
|
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||||
return;
|
$hasCustomDimensions = Device::query()
|
||||||
}
|
->where(function ($query): void {
|
||||||
if ($plugin->current_image === null) {
|
$query->where('width', '!=', 800)
|
||||||
return;
|
->orWhere('height', '!=', 480)
|
||||||
}
|
->orWhere('rotate', '!=', 0);
|
||||||
if (self::imageMetadataMatches($plugin->current_image_metadata, $deviceOrModel)) {
|
})
|
||||||
return;
|
->orWhereHas('deviceModel', function ($query): void {
|
||||||
}
|
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
|
||||||
$plugin->update([
|
$query->where(function ($subQuery): void {
|
||||||
'current_image' => null,
|
$subQuery->where('width', '!=', 800)
|
||||||
'current_image_metadata' => null,
|
->orWhere('height', '!=', 480)
|
||||||
]);
|
->orWhere('rotation', '!=', 0);
|
||||||
Log::debug("Plugin {$plugin->id}: cleared image cache due to metadata mismatch");
|
});
|
||||||
}
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
/**
|
if ($hasCustomDimensions) {
|
||||||
* Build canonical image metadata from a Device for cache comparison.
|
// TODO cache image per device
|
||||||
*
|
$plugin->update(['current_image' => null]);
|
||||||
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
|
Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist');
|
||||||
*/
|
|
||||||
public static function buildImageMetadataFromDevice(Device $device): array
|
|
||||||
{
|
|
||||||
$device->loadMissing(['deviceModel', 'deviceModel.palette']);
|
|
||||||
$settings = self::getImageSettings($device);
|
|
||||||
$paletteId = $device->palette_id ?? $device->deviceModel?->palette_id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'width' => $settings['width'],
|
|
||||||
'height' => $settings['height'],
|
|
||||||
'rotation' => $settings['rotation'] ?? 0,
|
|
||||||
'palette_id' => $paletteId,
|
|
||||||
'mime_type' => $settings['mime_type'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build canonical image metadata from a DeviceModel for cache comparison.
|
|
||||||
*
|
|
||||||
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
|
|
||||||
*/
|
|
||||||
public static function buildImageMetadataFromDeviceModel(DeviceModel $model): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'width' => $model->width,
|
|
||||||
'height' => $model->height,
|
|
||||||
'rotation' => $model->rotation ?? 0,
|
|
||||||
'palette_id' => $model->palette_id,
|
|
||||||
'mime_type' => $model->mime_type,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if stored metadata matches the current device or device model.
|
|
||||||
* Returns false if stored is null or empty so cache is regenerated and metadata is stored.
|
|
||||||
*/
|
|
||||||
public static function imageMetadataMatches(?array $stored, Device|DeviceModel $deviceOrModel): bool
|
|
||||||
{
|
|
||||||
if ($stored === null || $stored === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$current = $deviceOrModel instanceof Device
|
|
||||||
? self::buildImageMetadataFromDevice($deviceOrModel)
|
|
||||||
: self::buildImageMetadataFromDeviceModel($deviceOrModel);
|
|
||||||
|
|
||||||
foreach (['width', 'height', 'rotation', 'palette_id', 'mime_type'] as $key) {
|
|
||||||
if (($stored[$key] ?? null) !== ($current[$key] ?? null)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -566,7 +514,7 @@ class ImageGenerationService
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine device properties from DeviceModel or device settings
|
// Determine device properties from DeviceModel or device settings
|
||||||
$deviceVariant = $device->deviceModel?->css_name ?? $device->deviceVariant();
|
$deviceVariant = $device->deviceVariant();
|
||||||
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
||||||
$colorDepth = $device->colorDepth() ?? '1bit';
|
$colorDepth = $device->colorDepth() ?? '1bit';
|
||||||
$scaleLevel = $device->scaleLevel();
|
$scaleLevel = $device->scaleLevel();
|
||||||
|
|
@ -580,7 +528,6 @@ class ImageGenerationService
|
||||||
'deviceOrientation' => $deviceOrientation,
|
'deviceOrientation' => $deviceOrientation,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
'cssVariables' => $device->deviceModel?->css_variables ?? [],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add plugin name for error screens
|
// Add plugin name for error screens
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,7 @@ class IcalResponseParser implements ResponseParser
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Workaround for om/icalparser v4.0.0 bug where it fails if ORGANIZER or ATTENDEE has no parameters.
|
$this->parser->parseString($body);
|
||||||
// When ORGANIZER or ATTENDEE has no parameters (no semicolon after the key),
|
|
||||||
// IcalParser::parseRow returns an empty string for $middle instead of an array,
|
|
||||||
// which causes a type error in a foreach loop in IcalParser::parseString.
|
|
||||||
$normalizedBody = preg_replace('/^(ORGANIZER|ATTENDEE):/m', '$1;CN=Unknown:', $body);
|
|
||||||
$this->parser->parseString($normalizedBody);
|
|
||||||
|
|
||||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||||
$windowStart = now()->subDays(7);
|
$windowStart = now()->subDays(7);
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ class XmlResponseParser implements ResponseParser
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$xml = $this->simplexml_load_string_strip_namespaces($response->body());
|
$xml = simplexml_load_string($response->body());
|
||||||
if ($xml === false) {
|
if ($xml === false) {
|
||||||
throw new Exception('Invalid XML content');
|
throw new Exception('Invalid XML content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$xml->getName() => $this->xmlToArray($xml)];
|
return ['rss' => $this->xmlToArray($xml)];
|
||||||
} catch (Exception $exception) {
|
} catch (Exception $exception) {
|
||||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||||
|
|
||||||
|
|
@ -43,25 +43,4 @@ class XmlResponseParser implements ResponseParser
|
||||||
|
|
||||||
return $array;
|
return $array;
|
||||||
}
|
}
|
||||||
|
|
||||||
function simplexml_load_string_strip_namespaces($xml_response) {
|
|
||||||
$xml = simplexml_load_string($xml_response);
|
|
||||||
if ($xml === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$namespaces = array_keys($xml->getDocNamespaces(true));
|
|
||||||
$namespaces = array_filter($namespaces, function($name) { return !empty($name); });
|
|
||||||
if (count($namespaces) == 0) {
|
|
||||||
return $xml;
|
|
||||||
}
|
|
||||||
$namespaces = array_map(function($ns) { return "$ns:"; }, $namespaces);
|
|
||||||
|
|
||||||
$xml_no_namespaces = str_replace(
|
|
||||||
array_merge(["xmlns="], $namespaces),
|
|
||||||
array_merge(["ns="], array_fill(0, count($namespaces), '')),
|
|
||||||
$xml_response
|
|
||||||
);
|
|
||||||
return simplexml_load_string($xml_no_namespaces);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,35 +51,17 @@ class PluginExportService
|
||||||
$settings = $this->generateSettingsYaml($plugin);
|
$settings = $this->generateSettingsYaml($plugin);
|
||||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||||
|
// Generate full template content
|
||||||
|
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||||
|
|
||||||
// Export full template if it exists
|
|
||||||
if ($plugin->render_markup) {
|
|
||||||
$fullTemplate = $this->generateLayoutTemplate($plugin->render_markup);
|
|
||||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||||
|
// Generate shared.liquid if needed (for liquid templates)
|
||||||
|
if ($plugin->markup_language === 'liquid') {
|
||||||
|
$sharedTemplate = $this->generateSharedTemplate();
|
||||||
|
/** @phpstan-ignore-next-line */
|
||||||
|
if ($sharedTemplate) {
|
||||||
|
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export layout-specific templates if they exist
|
|
||||||
if ($plugin->render_markup_half_horizontal) {
|
|
||||||
$halfHorizontalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_horizontal);
|
|
||||||
File::put($tempDir.'/half_horizontal.'.$extension, $halfHorizontalTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($plugin->render_markup_half_vertical) {
|
|
||||||
$halfVerticalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_vertical);
|
|
||||||
File::put($tempDir.'/half_vertical.'.$extension, $halfVerticalTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($plugin->render_markup_quadrant) {
|
|
||||||
$quadrantTemplate = $this->generateLayoutTemplate($plugin->render_markup_quadrant);
|
|
||||||
File::put($tempDir.'/quadrant.'.$extension, $quadrantTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export shared template if it exists
|
|
||||||
if ($plugin->render_markup_shared) {
|
|
||||||
$sharedTemplate = $this->generateLayoutTemplate($plugin->render_markup_shared);
|
|
||||||
File::put($tempDir.'/shared.'.$extension, $sharedTemplate);
|
|
||||||
}
|
}
|
||||||
// Create ZIP file
|
// Create ZIP file
|
||||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||||
|
|
@ -142,21 +124,29 @@ class PluginExportService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate template content from markup, removing wrapper divs if present
|
* Generate the full template content
|
||||||
*/
|
*/
|
||||||
private function generateLayoutTemplate(?string $markup): string
|
private function generateFullTemplate(Plugin $plugin): string
|
||||||
{
|
{
|
||||||
if (! $markup) {
|
$markup = $plugin->render_markup;
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the wrapper div if it exists (it will be added during import for liquid)
|
// Remove the wrapper div if it exists (it will be added during import)
|
||||||
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
||||||
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
||||||
|
|
||||||
return mb_trim($markup);
|
return mb_trim($markup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the shared template content (for liquid templates)
|
||||||
|
*/
|
||||||
|
private function generateSharedTemplate(): null
|
||||||
|
{
|
||||||
|
// For now, we don't have a way to store shared templates separately
|
||||||
|
// TODO - add support for shared templates
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a directory and its contents to a ZIP file
|
* Add a directory and its contents to a ZIP file
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -93,59 +93,37 @@ class PluginImportService
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
$this->validateYAML($settings);
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Determine markup language from the first available file
|
// Determine which template file to use and read its content
|
||||||
|
$templatePath = null;
|
||||||
$markupLanguage = 'blade';
|
$markupLanguage = 'blade';
|
||||||
$firstTemplatePath = $filePaths['fullLiquidPath']
|
|
||||||
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['quadrantLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['sharedLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['sharedBladePath'] ?? null);
|
|
||||||
|
|
||||||
if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
|
if ($filePaths['fullLiquidPath']) {
|
||||||
$markupLanguage = 'liquid';
|
$templatePath = $filePaths['fullLiquidPath'];
|
||||||
|
$fullLiquid = File::get($templatePath);
|
||||||
|
|
||||||
|
// Prepend shared.liquid or shared.blade.php content if available
|
||||||
|
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
|
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||||
|
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||||
|
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||||
|
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||||
|
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read full markup (don't prepend shared - it will be prepended at render time)
|
// Check if the file ends with .liquid to set markup language
|
||||||
$fullLiquid = null;
|
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||||
if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
|
$markupLanguage = 'liquid';
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
}
|
}
|
||||||
}
|
} elseif ($filePaths['sharedLiquidPath']) {
|
||||||
|
$templatePath = $filePaths['sharedLiquidPath'];
|
||||||
// Read shared markup separately
|
$fullLiquid = File::get($templatePath);
|
||||||
$sharedMarkup = null;
|
$markupLanguage = 'liquid';
|
||||||
if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
$sharedMarkup = File::get($filePaths['sharedLiquidPath']);
|
} elseif ($filePaths['sharedBladePath']) {
|
||||||
} elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
$templatePath = $filePaths['sharedBladePath'];
|
||||||
$sharedMarkup = File::get($filePaths['sharedBladePath']);
|
$fullLiquid = File::get($templatePath);
|
||||||
}
|
$markupLanguage = 'blade';
|
||||||
|
|
||||||
// Read layout-specific markups
|
|
||||||
$halfHorizontalMarkup = null;
|
|
||||||
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
|
|
||||||
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$halfVerticalMarkup = null;
|
|
||||||
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
|
|
||||||
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$quadrantMarkup = null;
|
|
||||||
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
|
|
||||||
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -182,10 +160,6 @@ class PluginImportService
|
||||||
'polling_body' => $settings['polling_body'] ?? null,
|
'polling_body' => $settings['polling_body'] ?? null,
|
||||||
'markup_language' => $markupLanguage,
|
'markup_language' => $markupLanguage,
|
||||||
'render_markup' => $fullLiquid ?? null,
|
'render_markup' => $fullLiquid ?? null,
|
||||||
'render_markup_half_horizontal' => $halfHorizontalMarkup,
|
|
||||||
'render_markup_half_vertical' => $halfVerticalMarkup,
|
|
||||||
'render_markup_quadrant' => $quadrantMarkup,
|
|
||||||
'render_markup_shared' => $sharedMarkup,
|
|
||||||
'configuration_template' => $configurationTemplate,
|
'configuration_template' => $configurationTemplate,
|
||||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||||
]);
|
]);
|
||||||
|
|
@ -272,59 +246,37 @@ class PluginImportService
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
$this->validateYAML($settings);
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Determine markup language from the first available file
|
// Determine which template file to use and read its content
|
||||||
|
$templatePath = null;
|
||||||
$markupLanguage = 'blade';
|
$markupLanguage = 'blade';
|
||||||
$firstTemplatePath = $filePaths['fullLiquidPath']
|
|
||||||
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['quadrantLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['sharedLiquidPath'] ?? null)
|
|
||||||
?? ($filePaths['sharedBladePath'] ?? null);
|
|
||||||
|
|
||||||
if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
|
if ($filePaths['fullLiquidPath']) {
|
||||||
$markupLanguage = 'liquid';
|
$templatePath = $filePaths['fullLiquidPath'];
|
||||||
|
$fullLiquid = File::get($templatePath);
|
||||||
|
|
||||||
|
// Prepend shared.liquid or shared.blade.php content if available
|
||||||
|
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
|
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||||
|
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||||
|
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||||
|
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||||
|
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read full markup (don't prepend shared - it will be prepended at render time)
|
// Check if the file ends with .liquid to set markup language
|
||||||
$fullLiquid = null;
|
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||||
if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
|
$markupLanguage = 'liquid';
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
}
|
}
|
||||||
}
|
} elseif ($filePaths['sharedLiquidPath']) {
|
||||||
|
$templatePath = $filePaths['sharedLiquidPath'];
|
||||||
// Read shared markup separately
|
$fullLiquid = File::get($templatePath);
|
||||||
$sharedMarkup = null;
|
$markupLanguage = 'liquid';
|
||||||
if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
$sharedMarkup = File::get($filePaths['sharedLiquidPath']);
|
} elseif ($filePaths['sharedBladePath']) {
|
||||||
} elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
$templatePath = $filePaths['sharedBladePath'];
|
||||||
$sharedMarkup = File::get($filePaths['sharedBladePath']);
|
$fullLiquid = File::get($templatePath);
|
||||||
}
|
$markupLanguage = 'blade';
|
||||||
|
|
||||||
// Read layout-specific markups
|
|
||||||
$halfHorizontalMarkup = null;
|
|
||||||
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
|
|
||||||
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$halfVerticalMarkup = null;
|
|
||||||
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
|
|
||||||
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$quadrantMarkup = null;
|
|
||||||
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
|
|
||||||
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
|
|
||||||
if ($markupLanguage === 'liquid') {
|
|
||||||
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -370,10 +322,6 @@ class PluginImportService
|
||||||
'polling_body' => $settings['polling_body'] ?? null,
|
'polling_body' => $settings['polling_body'] ?? null,
|
||||||
'markup_language' => $markupLanguage,
|
'markup_language' => $markupLanguage,
|
||||||
'render_markup' => $fullLiquid ?? null,
|
'render_markup' => $fullLiquid ?? null,
|
||||||
'render_markup_half_horizontal' => $halfHorizontalMarkup,
|
|
||||||
'render_markup_half_vertical' => $halfVerticalMarkup,
|
|
||||||
'render_markup_quadrant' => $quadrantMarkup,
|
|
||||||
'render_markup_shared' => $sharedMarkup,
|
|
||||||
'configuration_template' => $configurationTemplate,
|
'configuration_template' => $configurationTemplate,
|
||||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||||
'preferred_renderer' => $preferredRenderer,
|
'preferred_renderer' => $preferredRenderer,
|
||||||
|
|
@ -409,9 +357,6 @@ class PluginImportService
|
||||||
$fullLiquidPath = null;
|
$fullLiquidPath = null;
|
||||||
$sharedLiquidPath = null;
|
$sharedLiquidPath = null;
|
||||||
$sharedBladePath = null;
|
$sharedBladePath = null;
|
||||||
$halfHorizontalLiquidPath = null;
|
|
||||||
$halfVerticalLiquidPath = null;
|
|
||||||
$quadrantLiquidPath = null;
|
|
||||||
|
|
||||||
// If zipEntryPath is specified, look for files in that specific directory first
|
// If zipEntryPath is specified, look for files in that specific directory first
|
||||||
if ($zipEntryPath) {
|
if ($zipEntryPath) {
|
||||||
|
|
@ -432,25 +377,6 @@ class PluginImportService
|
||||||
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
||||||
$sharedBladePath = $targetDir.'/shared.blade.php';
|
$sharedBladePath = $targetDir.'/shared.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for layout-specific files
|
|
||||||
if (File::exists($targetDir.'/half_horizontal.liquid')) {
|
|
||||||
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/half_horizontal.blade.php')) {
|
|
||||||
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($targetDir.'/half_vertical.liquid')) {
|
|
||||||
$halfVerticalLiquidPath = $targetDir.'/half_vertical.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/half_vertical.blade.php')) {
|
|
||||||
$halfVerticalLiquidPath = $targetDir.'/half_vertical.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($targetDir.'/quadrant.liquid')) {
|
|
||||||
$quadrantLiquidPath = $targetDir.'/quadrant.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/quadrant.blade.php')) {
|
|
||||||
$quadrantLiquidPath = $targetDir.'/quadrant.blade.php';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if files are in src subdirectory of target directory
|
// Check if files are in src subdirectory of target directory
|
||||||
|
|
@ -468,25 +394,6 @@ class PluginImportService
|
||||||
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
|
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
|
||||||
$sharedBladePath = $targetDir.'/src/shared.blade.php';
|
$sharedBladePath = $targetDir.'/src/shared.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for layout-specific files in src
|
|
||||||
if (File::exists($targetDir.'/src/half_horizontal.liquid')) {
|
|
||||||
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/src/half_horizontal.blade.php')) {
|
|
||||||
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($targetDir.'/src/half_vertical.liquid')) {
|
|
||||||
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/src/half_vertical.blade.php')) {
|
|
||||||
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($targetDir.'/src/quadrant.liquid')) {
|
|
||||||
$quadrantLiquidPath = $targetDir.'/src/quadrant.liquid';
|
|
||||||
} elseif (File::exists($targetDir.'/src/quadrant.blade.php')) {
|
|
||||||
$quadrantLiquidPath = $targetDir.'/src/quadrant.blade.php';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found the required files in the target directory, return them
|
// If we found the required files in the target directory, return them
|
||||||
|
|
@ -518,25 +425,6 @@ class PluginImportService
|
||||||
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||||
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for layout-specific files
|
|
||||||
if (File::exists($tempDir.'/src/half_horizontal.liquid')) {
|
|
||||||
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.liquid';
|
|
||||||
} elseif (File::exists($tempDir.'/src/half_horizontal.blade.php')) {
|
|
||||||
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($tempDir.'/src/half_vertical.liquid')) {
|
|
||||||
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.liquid';
|
|
||||||
} elseif (File::exists($tempDir.'/src/half_vertical.blade.php')) {
|
|
||||||
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.blade.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File::exists($tempDir.'/src/quadrant.liquid')) {
|
|
||||||
$quadrantLiquidPath = $tempDir.'/src/quadrant.liquid';
|
|
||||||
} elseif (File::exists($tempDir.'/src/quadrant.blade.php')) {
|
|
||||||
$quadrantLiquidPath = $tempDir.'/src/quadrant.blade.php';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Search for the files in the extracted directory structure
|
// Search for the files in the extracted directory structure
|
||||||
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
|
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||||
|
|
@ -554,12 +442,6 @@ class PluginImportService
|
||||||
$sharedLiquidPath = $filepath;
|
$sharedLiquidPath = $filepath;
|
||||||
} elseif ($filename === 'shared.blade.php') {
|
} elseif ($filename === 'shared.blade.php') {
|
||||||
$sharedBladePath = $filepath;
|
$sharedBladePath = $filepath;
|
||||||
} elseif ($filename === 'half_horizontal.liquid' || $filename === 'half_horizontal.blade.php') {
|
|
||||||
$halfHorizontalLiquidPath = $filepath;
|
|
||||||
} elseif ($filename === 'half_vertical.liquid' || $filename === 'half_vertical.blade.php') {
|
|
||||||
$halfVerticalLiquidPath = $filepath;
|
|
||||||
} elseif ($filename === 'quadrant.liquid' || $filename === 'quadrant.blade.php') {
|
|
||||||
$quadrantLiquidPath = $filepath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,25 +485,6 @@ class PluginImportService
|
||||||
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy layout-specific files if they exist
|
|
||||||
if ($halfHorizontalLiquidPath) {
|
|
||||||
$extension = pathinfo((string) $halfHorizontalLiquidPath, PATHINFO_EXTENSION);
|
|
||||||
File::copy($halfHorizontalLiquidPath, $newSrcDir.'/half_horizontal.'.$extension);
|
|
||||||
$halfHorizontalLiquidPath = $newSrcDir.'/half_horizontal.'.$extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($halfVerticalLiquidPath) {
|
|
||||||
$extension = pathinfo((string) $halfVerticalLiquidPath, PATHINFO_EXTENSION);
|
|
||||||
File::copy($halfVerticalLiquidPath, $newSrcDir.'/half_vertical.'.$extension);
|
|
||||||
$halfVerticalLiquidPath = $newSrcDir.'/half_vertical.'.$extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quadrantLiquidPath) {
|
|
||||||
$extension = pathinfo((string) $quadrantLiquidPath, PATHINFO_EXTENSION);
|
|
||||||
File::copy($quadrantLiquidPath, $newSrcDir.'/quadrant.'.$extension);
|
|
||||||
$quadrantLiquidPath = $newSrcDir.'/quadrant.'.$extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the paths
|
// Update the paths
|
||||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||||
}
|
}
|
||||||
|
|
@ -633,9 +496,6 @@ class PluginImportService
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
'sharedBladePath' => $sharedBladePath,
|
'sharedBladePath' => $sharedBladePath,
|
||||||
'halfHorizontalLiquidPath' => $halfHorizontalLiquidPath,
|
|
||||||
'halfVerticalLiquidPath' => $halfVerticalLiquidPath,
|
|
||||||
'quadrantLiquidPath' => $quadrantLiquidPath,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.2",
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
"ext-simplexml": "*",
|
"ext-simplexml": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "^2.3",
|
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||||
"bnussbau/trmnl-pipeline-php": "^0.8",
|
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||||
"keepsuit/laravel-liquid": "^0.5.2",
|
"keepsuit/laravel-liquid": "^0.5.2",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.1",
|
"laravel/framework": "^12.1",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/livewire": "^4.0",
|
"livewire/livewire": "^4.0",
|
||||||
"om/icalparser": "^4.0",
|
"om/icalparser": "^3.2",
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
"spatie/laravel-settings": "^3.6",
|
"spatie/laravel-settings": "^3.6",
|
||||||
"stevebauman/purify": "^6.3",
|
"stevebauman/purify": "^6.3",
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"larastan/larastan": "^3.0",
|
"larastan/larastan": "^3.0",
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^1.0",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
|
|
|
||||||
1206
composer.lock
generated
|
|
@ -13,7 +13,7 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'LaraPaper'),
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
@ -127,13 +127,11 @@ return [
|
||||||
'enabled' => env('REGISTRATION_ENABLED', true),
|
'enabled' => env('REGISTRATION_ENABLED', true),
|
||||||
],
|
],
|
||||||
|
|
||||||
'pixel_logo_enabled' => env('PIXELLOGO_ENABLED', true),
|
|
||||||
|
|
||||||
'force_https' => env('FORCE_HTTPS', false),
|
'force_https' => env('FORCE_HTTPS', false),
|
||||||
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
|
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
|
||||||
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
|
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
|
||||||
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
|
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
|
||||||
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', 'v2'),
|
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
|
||||||
|
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
'battery_low' => [
|
'battery_low' => [
|
||||||
|
|
@ -156,5 +154,5 @@ return [
|
||||||
|
|
||||||
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
||||||
|
|
||||||
'github_repo' => env('GITHUB_REPO', 'usetrmnl/larapaper'),
|
'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'trmnl' => [
|
'trmnl' => [
|
||||||
'base_url' => 'https://trmnl.com',
|
|
||||||
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
|
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
|
||||||
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
||||||
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?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('plugins', function (Blueprint $table) {
|
|
||||||
$table->text('render_markup_half_horizontal')->nullable()->after('render_markup');
|
|
||||||
$table->text('render_markup_half_vertical')->nullable()->after('render_markup_half_horizontal');
|
|
||||||
$table->text('render_markup_quadrant')->nullable()->after('render_markup_half_vertical');
|
|
||||||
$table->text('render_markup_shared')->nullable()->after('render_markup_quadrant');
|
|
||||||
$table->text('transform_code')->nullable()->after('render_markup_shared');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('plugins', function (Blueprint $table) {
|
|
||||||
$table->dropColumn([
|
|
||||||
'render_markup_half_horizontal',
|
|
||||||
'render_markup_half_vertical',
|
|
||||||
'render_markup_quadrant',
|
|
||||||
'render_markup_shared',
|
|
||||||
'transform_code',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('devices', function (Blueprint $table): void {
|
|
||||||
$table->boolean('maximum_compatibility')->default(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('devices', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('maximum_compatibility');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?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('device_models', function (Blueprint $table) {
|
|
||||||
$table->string('css_name')->nullable()->after('kind');
|
|
||||||
$table->json('css_variables')->nullable()->after('css_name');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('device_models', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['css_name', 'css_variables']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\DeviceModel;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* CSS name and variables for device models created by seed_device_models (og_png until inky_impression_13_3).
|
|
||||||
*
|
|
||||||
* @var array<string, array{css_name: string, css_variables: array<string, string>}>
|
|
||||||
*/
|
|
||||||
private const SEEDED_CSS = [
|
|
||||||
'og_png' => [
|
|
||||||
'css_name' => 'og_png',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '480px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'og_plus' => [
|
|
||||||
'css_name' => 'ogv2',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '480px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'amazon_kindle_2024' => [
|
|
||||||
'css_name' => 'amazon_kindle_2024',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '480px',
|
|
||||||
'--ui-scale' => '0.8',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'amazon_kindle_paperwhite_6th_gen' => [
|
|
||||||
'css_name' => 'amazon_kindle_paperwhite_6th_gen',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '600px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'amazon_kindle_paperwhite_7th_gen' => [
|
|
||||||
'css_name' => 'amazon_kindle_paperwhite_7th_gen',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '905px',
|
|
||||||
'--screen-h' => '670px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'inkplate_10' => [
|
|
||||||
'css_name' => 'inkplate_10',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '547px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'amazon_kindle_7' => [
|
|
||||||
'css_name' => 'amazon_kindle_7',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '600px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'inky_impression_7_3' => [
|
|
||||||
'css_name' => 'inky_impression_7_3',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '480px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'kobo_libra_2' => [
|
|
||||||
'css_name' => 'kobo_libra_2',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '602px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'amazon_kindle_oasis_2' => [
|
|
||||||
'css_name' => 'amazon_kindle_oasis_2',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '602px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'kobo_aura_one' => [
|
|
||||||
'css_name' => 'kobo_aura_one',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '1040px',
|
|
||||||
'--screen-h' => '780px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'kobo_aura_hd' => [
|
|
||||||
'css_name' => 'kobo_aura_hd',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '600px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'inky_impression_13_3' => [
|
|
||||||
'css_name' => 'inky_impression_13_3',
|
|
||||||
'css_variables' => [
|
|
||||||
'--screen-w' => '800px',
|
|
||||||
'--screen-h' => '600px',
|
|
||||||
'--ui-scale' => '1.0',
|
|
||||||
'--gap-scale' => '1.0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
foreach (self::SEEDED_CSS as $name => $payload) {
|
|
||||||
DeviceModel::query()
|
|
||||||
->where('name', $name)
|
|
||||||
->update([
|
|
||||||
'css_name' => $payload['css_name'],
|
|
||||||
'css_variables' => $payload['css_variables'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
DeviceModel::query()
|
|
||||||
->whereIn('name', array_keys(self::SEEDED_CSS))
|
|
||||||
->update([
|
|
||||||
'css_name' => null,
|
|
||||||
'css_variables' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?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('plugins', function (Blueprint $table) {
|
|
||||||
$table->json('current_image_metadata')->nullable()->after('current_image');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('plugins', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('current_image_metadata');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\Playlist;
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
@ -24,19 +23,9 @@ class DatabaseSeeder extends Seeder
|
||||||
'password' => bcrypt('admin@example.com'),
|
'password' => bcrypt('admin@example.com'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$device = Device::factory()->create([
|
Device::factory(1)->create([
|
||||||
'mac_address' => '00:00:00:00:00:00',
|
'mac_address' => '00:00:00:00:00:00',
|
||||||
'api_key' => 'test-api-key',
|
'api_key' => 'test-api-key',
|
||||||
'proxy_cloud' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Playlist::factory()->create([
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'name' => 'Default',
|
|
||||||
'is_active' => true,
|
|
||||||
'active_from' => null,
|
|
||||||
'active_until' => null,
|
|
||||||
'weekdays' => null
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Device::factory(5)->create();
|
// Device::factory(5)->create();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ghcr.io/usetrmnl/larapaper:latest
|
image: ghcr.io/usetrmnl/byos_laravel:latest
|
||||||
ports:
|
ports:
|
||||||
- "4567:8080"
|
- "4567:8080"
|
||||||
environment:
|
environment:
|
||||||
# Generate the APP_KEY with `echo "base64:$(openssl rand -base64 32)"`
|
|
||||||
#- APP_KEY=
|
#- APP_KEY=
|
||||||
- PHP_OPCACHE_ENABLE=1
|
- PHP_OPCACHE_ENABLE=1
|
||||||
- TRMNL_PROXY_REFRESH_MINUTES=15
|
- TRMNL_PROXY_REFRESH_MINUTES=15
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
#### Clone the repository
|
#### Clone the repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:usetrmnl/larapaper.git
|
git clone git@github.com:usetrmnl/byos_laravel.git
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Copy environment file
|
#### Copy environment file
|
||||||
|
|
|
||||||
1013
package-lock.json
generated
|
|
@ -12,7 +12,6 @@
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-liquid": "^6.3.0",
|
"@codemirror/lang-liquid": "^6.3.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
|
||||||
"@codemirror/language": "^6.11.3",
|
"@codemirror/language": "^6.11.3",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
|
|
@ -25,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.37.0",
|
"puppeteer": "24.30.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
refreshTimer: null,
|
refreshTimer: null,
|
||||||
renderedAt: 0,
|
renderedAt: 0,
|
||||||
ui: {},
|
ui: {},
|
||||||
wakeLock: null,
|
|
||||||
|
|
||||||
showStatus: function (message) {
|
showStatus: function (message) {
|
||||||
trmnl.ui.img.style.display = "none";
|
trmnl.ui.img.style.display = "none";
|
||||||
|
|
@ -40,9 +39,8 @@
|
||||||
var data = trmnl.getSettings();
|
var data = trmnl.getSettings();
|
||||||
trmnl.ui.apiKeyInput.value = data.api_key || "";
|
trmnl.ui.apiKeyInput.value = data.api_key || "";
|
||||||
trmnl.ui.baseURLInput.value = data.base_url || "";
|
trmnl.ui.baseURLInput.value = data.base_url || "";
|
||||||
|
trmnl.ui.macAddressInput.value = data.mac_address || "";
|
||||||
trmnl.ui.displayModeSelect.value = data.display_mode || "";
|
trmnl.ui.displayModeSelect.value = data.display_mode || "";
|
||||||
trmnl.ui.fullscreenToggle.checked = !!data.fullscreen;
|
|
||||||
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock || !!data.wake_lock;
|
|
||||||
|
|
||||||
trmnl.ui.setup.style.display = "flex";
|
trmnl.ui.setup.style.display = "flex";
|
||||||
},
|
},
|
||||||
|
|
@ -52,9 +50,8 @@
|
||||||
|
|
||||||
var apiKey = trmnl.ui.apiKeyInput.value;
|
var apiKey = trmnl.ui.apiKeyInput.value;
|
||||||
var baseURL = trmnl.ui.baseURLInput.value;
|
var baseURL = trmnl.ui.baseURLInput.value;
|
||||||
|
var macAddress = trmnl.ui.macAddressInput.value;
|
||||||
var displayMode = trmnl.ui.displayModeSelect.value;
|
var displayMode = trmnl.ui.displayModeSelect.value;
|
||||||
var fullscreenEnabled = trmnl.ui.fullscreenToggle.checked;
|
|
||||||
var wakeLockEnabled = trmnl.ui.wakeLockToggle.checked;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -63,26 +60,10 @@
|
||||||
trmnl.saveSettings({
|
trmnl.saveSettings({
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
base_url: baseURL,
|
base_url: baseURL,
|
||||||
display_mode: displayMode,
|
mac_address: macAddress,
|
||||||
fullscreen: fullscreenEnabled,
|
display_mode: displayMode
|
||||||
wake_lock: wakeLockEnabled
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (wakeLockEnabled) {
|
|
||||||
trmnl.acquireWakeLock().then(function () {
|
|
||||||
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
|
||||||
}).catch(function (err) {
|
|
||||||
console.warn("Wake Lock request failed:", err);
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
trmnl.releaseWakeLock().then(function () {
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
}).catch(function (err) {
|
|
||||||
console.warn("Wake Lock release failed:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
trmnl.fetchDisplay();
|
trmnl.fetchDisplay();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -90,144 +71,6 @@
|
||||||
trmnl.ui.setup.style.display = "none";
|
trmnl.ui.setup.style.display = "none";
|
||||||
},
|
},
|
||||||
|
|
||||||
isFullscreenSupported: function () {
|
|
||||||
return !!(
|
|
||||||
document.fullscreenEnabled ||
|
|
||||||
document.webkitFullscreenEnabled ||
|
|
||||||
document.msFullscreenEnabled
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
isFullscreenActive: function () {
|
|
||||||
return !!(
|
|
||||||
document.fullscreenElement ||
|
|
||||||
document.webkitFullscreenElement ||
|
|
||||||
document.msFullscreenElement
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
enterFullscreen: function () {
|
|
||||||
if (!trmnl.isFullscreenSupported()) return;
|
|
||||||
|
|
||||||
var el = document.documentElement;
|
|
||||||
var promise;
|
|
||||||
|
|
||||||
if (el.requestFullscreen) {
|
|
||||||
promise = el.requestFullscreen();
|
|
||||||
} else if (el.webkitRequestFullscreen) {
|
|
||||||
promise = el.webkitRequestFullscreen();
|
|
||||||
} else if (el.msRequestFullscreen) {
|
|
||||||
promise = el.msRequestFullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promise && promise.catch) {
|
|
||||||
promise.catch(function (err) {
|
|
||||||
console.warn("Enter fullscreen failed:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
exitFullscreen: function () {
|
|
||||||
if (!trmnl.isFullscreenSupported()) return;
|
|
||||||
if (!trmnl.isFullscreenActive()) return;
|
|
||||||
|
|
||||||
var promise;
|
|
||||||
|
|
||||||
if (document.exitFullscreen) {
|
|
||||||
promise = document.exitFullscreen();
|
|
||||||
} else if (document.webkitExitFullscreen) {
|
|
||||||
promise = document.webkitExitFullscreen();
|
|
||||||
} else if (document.msExitFullscreen) {
|
|
||||||
promise = document.msExitFullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promise && promise.catch) {
|
|
||||||
promise.catch(function (err) {
|
|
||||||
console.warn("Exit fullscreen failed:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
syncFullscreenToggle: function () {
|
|
||||||
var active = trmnl.isFullscreenActive();
|
|
||||||
trmnl.ui.fullscreenToggle.checked = active;
|
|
||||||
},
|
|
||||||
|
|
||||||
isWakeLockSupported: function () {
|
|
||||||
return (
|
|
||||||
window.isSecureContext &&
|
|
||||||
navigator.wakeLock &&
|
|
||||||
typeof navigator.wakeLock.request === "function"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
acquireWakeLock: function () {
|
|
||||||
|
|
||||||
if (!trmnl.isWakeLockSupported()) {
|
|
||||||
return {
|
|
||||||
then: function () { return this; },
|
|
||||||
catch: function () { return this; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trmnl.wakeLock) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return navigator.wakeLock.request("screen")
|
|
||||||
.then(function (sentinel) {
|
|
||||||
|
|
||||||
trmnl.wakeLock = sentinel;
|
|
||||||
|
|
||||||
sentinel.addEventListener("release", function () {
|
|
||||||
trmnl.wakeLock = null;
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Wake Lock attivo");
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
console.warn("Wake Lock failed:", err);
|
|
||||||
trmnl.wakeLock = null;
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
releaseWakeLock: function () {
|
|
||||||
|
|
||||||
if (!trmnl.wakeLock) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return trmnl.wakeLock.release()
|
|
||||||
.then(function () {
|
|
||||||
trmnl.wakeLock = null;
|
|
||||||
console.log("Wake Lock rilasciato");
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
console.warn("Release failed:", err);
|
|
||||||
trmnl.wakeLock = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
toggleWakeLock: function () {
|
|
||||||
if (!trmnl.isWakeLockSupported()) return;
|
|
||||||
if (trmnl.wakeLock) {
|
|
||||||
trmnl.releaseWakeLock().then(function () {
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
trmnl.acquireWakeLock().then(function () {
|
|
||||||
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fetchDisplay: function (opts) {
|
fetchDisplay: function (opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
clearTimeout(trmnl.refreshTimer);
|
clearTimeout(trmnl.refreshTimer);
|
||||||
|
|
@ -241,6 +84,7 @@
|
||||||
var apiKey = setup.api_key;
|
var apiKey = setup.api_key;
|
||||||
var displayMode = setup.display_mode;
|
var displayMode = setup.display_mode;
|
||||||
var baseURL = setup.base_url || "https://your-byos-trmnl.com";
|
var baseURL = setup.base_url || "https://your-byos-trmnl.com";
|
||||||
|
var macAddress = setup.mac_address || "00:00:00:00:00:01";
|
||||||
|
|
||||||
document.body.classList.remove("dark", "night")
|
document.body.classList.remove("dark", "night")
|
||||||
if (displayMode) {
|
if (displayMode) {
|
||||||
|
|
@ -248,7 +92,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers = {
|
var headers = {
|
||||||
"Access-Token": apiKey
|
"Access-Token": apiKey,
|
||||||
|
"id": macAddress
|
||||||
};
|
};
|
||||||
|
|
||||||
var url = baseURL + "/api/display";
|
var url = baseURL + "/api/display";
|
||||||
|
|
@ -292,12 +137,8 @@
|
||||||
trmnl.showStatus("Error processing response: " + e.message);
|
trmnl.showStatus("Error processing response: " + e.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var msg = xhr.statusText
|
|
||||||
if (xhr.status == 404) {
|
|
||||||
msg = "Maybe wrong API key";
|
|
||||||
}
|
|
||||||
trmnl.showStatus(
|
trmnl.showStatus(
|
||||||
"Failed to fetch screen: " + xhr.status + " " + msg
|
"Failed to fetch screen: " + xhr.status + " " + xhr.statusText
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -369,6 +210,10 @@
|
||||||
hasOverrides = true;
|
hasOverrides = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "mac_address" && value) {
|
||||||
|
newSettings.mac_address = value;
|
||||||
|
hasOverrides = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasOverrides) {
|
if (hasOverrides) {
|
||||||
|
|
@ -429,111 +274,17 @@
|
||||||
// settings
|
// settings
|
||||||
trmnl.ui.apiKeyInput = document.getElementById("api_key");
|
trmnl.ui.apiKeyInput = document.getElementById("api_key");
|
||||||
trmnl.ui.baseURLInput = document.getElementById("base_url");
|
trmnl.ui.baseURLInput = document.getElementById("base_url");
|
||||||
|
trmnl.ui.macAddressInput = document.getElementById("mac_address");
|
||||||
trmnl.ui.displayModeSelect = document.getElementById("display_mode");
|
trmnl.ui.displayModeSelect = document.getElementById("display_mode");
|
||||||
trmnl.ui.fullscreenToggle = document.getElementById("fullscreenToggle");
|
|
||||||
trmnl.ui.wakeLockToggle = document.getElementById("wakeLockToggle");
|
|
||||||
trmnl.ui.setup = document.getElementById("setup");
|
trmnl.ui.setup = document.getElementById("setup");
|
||||||
|
|
||||||
// Sync fullscreen state
|
|
||||||
document.addEventListener("fullscreenchange", trmnl.syncFullscreenToggle);
|
|
||||||
document.addEventListener("webkitfullscreenchange", trmnl.syncFullscreenToggle);
|
|
||||||
document.addEventListener("msfullscreenchange", trmnl.syncFullscreenToggle);
|
|
||||||
|
|
||||||
// Fullscreen toggle
|
|
||||||
if (!trmnl.isFullscreenSupported()) {
|
|
||||||
trmnl.ui.fullscreenToggle.disabled = true;
|
|
||||||
trmnl.ui.fullscreenToggle.parentElement.style.opacity = "0.5";
|
|
||||||
trmnl.ui.fullscreenToggle.parentElement.style.cursor = "not-allowed";
|
|
||||||
} else {
|
|
||||||
trmnl.ui.fullscreenToggle.addEventListener("change", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (e.target.checked) {
|
|
||||||
trmnl.enterFullscreen();
|
|
||||||
} else {
|
|
||||||
trmnl.exitFullscreen();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var wakeLockHint = document.getElementById("wakeLockHint");
|
|
||||||
|
|
||||||
// Wake Lock toggle
|
|
||||||
if (trmnl.isWakeLockSupported()) {
|
|
||||||
|
|
||||||
trmnl.ui.wakeLockToggle.disabled = false;
|
|
||||||
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "1";
|
|
||||||
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "pointer";
|
|
||||||
|
|
||||||
if (wakeLockHint) wakeLockHint.style.display = "none";
|
|
||||||
|
|
||||||
trmnl.ui.wakeLockToggle.addEventListener("change", function () {
|
|
||||||
trmnl.toggleWakeLock();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", function () {
|
|
||||||
if (
|
|
||||||
document.visibilityState === "visible" &&
|
|
||||||
trmnl.ui.wakeLockToggle.checked
|
|
||||||
) {
|
|
||||||
trmnl.acquireWakeLock();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
// unsupported (HTTP or old browser)
|
|
||||||
trmnl.ui.wakeLockToggle.disabled = true;
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "0.5";
|
|
||||||
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "not-allowed";
|
|
||||||
|
|
||||||
if (!window.isSecureContext && wakeLockHint) {
|
|
||||||
wakeLockHint.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// get settings from localstorage
|
|
||||||
var settings = trmnl.getSettings();
|
var settings = trmnl.getSettings();
|
||||||
|
|
||||||
// show setup form if missing apikey
|
|
||||||
if (!settings || !settings.api_key) {
|
if (!settings || !settings.api_key) {
|
||||||
trmnl.showSetupForm();
|
trmnl.showSetupForm();
|
||||||
} else {
|
} else {
|
||||||
trmnl.fetchDisplay();
|
trmnl.fetchDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto fullscreen at first click/touch if option enabled
|
|
||||||
if (settings.fullscreen && trmnl.isFullscreenSupported()) {
|
|
||||||
var activateFullscreenOnce = function () {
|
|
||||||
trmnl.enterFullscreen();
|
|
||||||
document.removeEventListener("click", activateFullscreenOnce);
|
|
||||||
document.removeEventListener("touchstart", activateFullscreenOnce);
|
|
||||||
};
|
|
||||||
document.addEventListener("click", activateFullscreenOnce, { once: true });
|
|
||||||
document.addEventListener("touchstart", activateFullscreenOnce, { once: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto Wake Lock at first click/touch if option enabled
|
|
||||||
if (settings.wake_lock && trmnl.isWakeLockSupported()) {
|
|
||||||
var acquireWakeLockOnce = function () {
|
|
||||||
trmnl.acquireWakeLock().then(function () {
|
|
||||||
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
|
||||||
}).catch(function (err) {
|
|
||||||
console.warn("Wake Lock request failed:", err);
|
|
||||||
trmnl.ui.wakeLockToggle.checked = false;
|
|
||||||
});
|
|
||||||
document.removeEventListener("click", acquireWakeLockOnce);
|
|
||||||
document.removeEventListener("touchstart", acquireWakeLockOnce);
|
|
||||||
};
|
|
||||||
document.addEventListener("click", acquireWakeLockOnce, { once: true });
|
|
||||||
document.addEventListener("touchstart", acquireWakeLockOnce, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
trmnl.syncFullscreenToggle();
|
|
||||||
} //init end
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
@ -652,7 +403,8 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label,
|
||||||
|
summary {
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -681,10 +433,6 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-clear {
|
.btn-clear {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
background-color: #777;
|
background-color: #777;
|
||||||
|
|
@ -709,127 +457,8 @@
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row {
|
#unsupported {
|
||||||
display: flex;
|
color: red;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row label,
|
|
||||||
.setting-row .toggle-label {
|
|
||||||
font-size: 1.25em;
|
|
||||||
margin: 0;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row select,
|
|
||||||
.setting-row .switch {
|
|
||||||
width: auto;
|
|
||||||
min-width: 52px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 52px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
inset: 0;
|
|
||||||
background-color: #ccc;
|
|
||||||
border-radius: 28px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
left: 3px;
|
|
||||||
top: 3px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input:checked+.slider {
|
|
||||||
background-color: #f54900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input:checked+.slider::before {
|
|
||||||
transform: translateX(24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input:disabled+.slider {
|
|
||||||
background-color: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input:disabled+.slider::before {
|
|
||||||
background-color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select-small {
|
|
||||||
width: 6em;
|
|
||||||
font-size: 1em;
|
|
||||||
padding: 0.4em 0.5em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 1.25em;
|
|
||||||
margin: 0;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-hint {
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: #f41414;
|
|
||||||
margin-top: 0.2em;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback for iOS 9 */
|
|
||||||
@media screen and (max-width: 1024px) and (-webkit-min-device-pixel-ratio: 1) {
|
|
||||||
.setting-row {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row label,
|
|
||||||
.setting-row .toggle-label {
|
|
||||||
float: left;
|
|
||||||
line-height: 28px;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row select,
|
|
||||||
.setting-row .switch {
|
|
||||||
float: right;
|
|
||||||
width: auto;
|
|
||||||
min-width: 52px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-hint {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -840,11 +469,10 @@
|
||||||
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
|
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
|
||||||
|
|
||||||
<form onsubmit="return trmnl.saveSetup(event)">
|
<form onsubmit="return trmnl.saveSetup(event)">
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="base_url">Custom Server URL</label>
|
<label for="mac_address">Device MAC Address</label>
|
||||||
<input name="base_url" id="base_url" type="text" placeholder="https://your-byos-trmnl.com"
|
<input name="mac_address" id="mac_address" type="text" placeholder="00:00:00:00:00:01" class="form-control"
|
||||||
class="form-control" value="" />
|
required />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
@ -852,38 +480,25 @@
|
||||||
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
|
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="setting-row">
|
<fieldset>
|
||||||
<label for="display_mode">Display Mode</label>
|
<select id="display_mode" name="display_mode">
|
||||||
<select id="display_mode" name="display_mode" class="form-select-small">
|
<option value="" selected="selected">Light Mode</option>
|
||||||
<option value="" selected>Light</option>
|
<option value="dark">Dark Mode</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="night">Night Mode</option>
|
||||||
<option value="night">Night</option>
|
|
||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="setting-row">
|
<fieldset>
|
||||||
<span class="toggle-label">Fullscreen</span>
|
<label for="base_url">Custom Server URL</label>
|
||||||
<label class="switch">
|
<input name="base_url" id="base_url" type="text" placeholder="https://your-byos-trmnl.com"
|
||||||
<input type="checkbox" id="fullscreenToggle">
|
class="form-control" value="" />
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="setting-row">
|
|
||||||
<div>
|
|
||||||
<span class="toggle-label">Screen Wake Lock</span>
|
|
||||||
<div id="wakeLockHint" class="setting-hint" style="display:none;">
|
|
||||||
Require HTTPS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="wakeLockToggle">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button class="btn">Save</button>
|
<button class="btn">Save</button>
|
||||||
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">Clear settings</button>
|
|
||||||
|
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">
|
||||||
|
Clear settings
|
||||||
|
</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -897,11 +512,10 @@
|
||||||
<div id="error-message"></div>
|
<div id="error-message"></div>
|
||||||
<div style="display: flex; margin-top: 1em">
|
<div style="display: flex; margin-top: 1em">
|
||||||
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
|
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
|
||||||
<button class="btn btn-secondary" onclick="trmnl.fetchDisplay()">Retry</button>
|
<button class="btn" onclick="trmnl.fetchDisplay()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -9,7 +9,6 @@ import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
import { css } from '@codemirror/lang-css';
|
import { css } from '@codemirror/lang-css';
|
||||||
import { liquid } from '@codemirror/lang-liquid';
|
import { liquid } from '@codemirror/lang-liquid';
|
||||||
import { yaml } from '@codemirror/lang-yaml';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
|
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
|
||||||
|
|
||||||
|
|
@ -21,8 +20,6 @@ const LANGUAGE_MAP = {
|
||||||
'css': css,
|
'css': css,
|
||||||
'liquid': liquid,
|
'liquid': liquid,
|
||||||
'html': html,
|
'html': html,
|
||||||
'yaml': yaml,
|
|
||||||
'yml': yaml,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme support mapping
|
// Theme support mapping
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,5 @@
|
||||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||||
@if(config('app.pixel_logo_enabled'))
|
<span class="mb-0.5 truncate leading-none font-semibold">TRMNL BYOS Laravel</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 pt-1.5 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
|
|
||||||
@else
|
|
||||||
<span class="mb-0.5 truncate leading-none font-semibold">LaraPaper</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@
|
||||||
])
|
])
|
||||||
|
|
||||||
<div class="flex w-full flex-col text-center">
|
<div class="flex w-full flex-col text-center">
|
||||||
@if(config('app.pixel_logo_enabled'))
|
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
|
|
||||||
@else
|
|
||||||
<flux:heading size="xl">LaraPaper</flux:heading>
|
|
||||||
@endif
|
|
||||||
<flux:subheading>{{ $description }}</flux:subheading>
|
<flux:subheading>{{ $description }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,12 @@
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
'pluginName' => 'Recipe',
|
'pluginName' => 'Recipe',
|
||||||
])
|
])
|
||||||
|
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
scale-level="{{$scaleLevel}}"
|
scale-level="{{$scaleLevel}}">
|
||||||
:css-variables="$cssVariables">
|
|
||||||
<x-trmnl::view>
|
<x-trmnl::view>
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::richtext gapSize="large" align="center">
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,18 @@
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
scale-level="{{$scaleLevel}}"
|
scale-level="{{$scaleLevel}}">
|
||||||
:css-variables="$cssVariables">
|
|
||||||
<x-trmnl::view>
|
<x-trmnl::view>
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::richtext gapSize="large" align="center">
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
<x-trmnl::title>Welcome to LaraPaper!</x-trmnl::title>
|
<x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title>
|
||||||
<x-trmnl::content>Your device is connected.</x-trmnl::content>
|
<x-trmnl::content>Your device is connected.</x-trmnl::content>
|
||||||
</x-trmnl::richtext>
|
</x-trmnl::richtext>
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
<x-trmnl::title-bar title="LaraPaper"/>
|
<x-trmnl::title-bar title="byos_laravel"/>
|
||||||
</x-trmnl::view>
|
</x-trmnl::view>
|
||||||
</x-trmnl::screen>
|
</x-trmnl::screen>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,11 @@
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
scale-level="{{$scaleLevel}}"
|
scale-level="{{$scaleLevel}}">
|
||||||
:css-variables="$cssVariables">
|
|
||||||
<x-trmnl::view>
|
<x-trmnl::view>
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::richtext gapSize="large" align="center">
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
|
|
@ -25,6 +23,6 @@
|
||||||
<x-trmnl::title>Sleep Mode</x-trmnl::title>
|
<x-trmnl::title>Sleep Mode</x-trmnl::title>
|
||||||
</x-trmnl::richtext>
|
</x-trmnl::richtext>
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
<x-trmnl::title-bar title="LaraPaper"/>
|
<x-trmnl::title-bar title="byos_laravel"/>
|
||||||
</x-trmnl::view>
|
</x-trmnl::view>
|
||||||
</x-trmnl::screen>
|
</x-trmnl::screen>
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,10 @@ class extends Component
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
||||||
$response = Cache::remember($cacheKey, 43200, function () {
|
$response = Cache::remember($cacheKey, 43200, function () {
|
||||||
$response = Http::timeout(10)->get(
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
config('services.trmnl.base_url').'/recipes.json',
|
|
||||||
[
|
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
'page' => $this->page,
|
'page' => $this->page,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
||||||
|
|
@ -89,14 +86,11 @@ class extends Component
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
||||||
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||||
$response = Http::get(
|
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||||
config('services.trmnl.base_url').'/recipes.json',
|
|
||||||
[
|
|
||||||
'search' => $term,
|
'search' => $term,
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
'page' => $this->page,
|
'page' => $this->page,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new RuntimeException('Failed to search TRMNL recipes');
|
throw new RuntimeException('Failed to search TRMNL recipes');
|
||||||
|
|
@ -161,7 +155,7 @@ class extends Component
|
||||||
abort_unless(auth()->user() !== null, 403);
|
abort_unless(auth()->user() !== null, 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$zipUrl = config('services.trmnl.base_url')."/api/plugin_settings/{$recipeId}/archive";
|
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
|
||||||
|
|
||||||
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
|
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
|
||||||
|
|
||||||
|
|
@ -189,21 +183,16 @@ class extends Component
|
||||||
$this->previewData = [];
|
$this->previewData = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = Http::timeout(10)->get(
|
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
|
||||||
config('services.trmnl.base_url')."/recipes/{$recipeId}.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$item = $response->json()['data'] ?? [];
|
$item = $response->json()['data'] ?? [];
|
||||||
$this->previewData = $this->mapRecipe($item);
|
$this->previewData = $this->mapRecipe($item);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to searching for the specific recipe if single endpoint doesn't exist
|
// Fallback to searching for the specific recipe if single endpoint doesn't exist
|
||||||
$response = Http::timeout(10)->get(
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
config('services.trmnl.base_url').'/recipes.json',
|
|
||||||
[
|
|
||||||
'search' => $recipeId,
|
'search' => $recipeId,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$data = $response->json()['data'] ?? [];
|
$data = $response->json()['data'] ?? [];
|
||||||
|
|
@ -251,9 +240,7 @@ class extends Component
|
||||||
'installs' => data_get($item, 'stats.installs'),
|
'installs' => data_get($item, 'stats.installs'),
|
||||||
'forks' => data_get($item, 'stats.forks'),
|
'forks' => data_get($item, 'stats.forks'),
|
||||||
],
|
],
|
||||||
'detail_url' => isset($item['id'])
|
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
|
||||||
? config('services.trmnl.base_url').'/recipes/'.$item['id']
|
|
||||||
: null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\FetchDeviceModelsJob;
|
|
||||||
use App\Models\DeviceModel;
|
use App\Models\DeviceModel;
|
||||||
use App\Models\DevicePalette;
|
use App\Models\DevicePalette;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -39,11 +38,6 @@ new class extends Component
|
||||||
|
|
||||||
public $palette_id;
|
public $palette_id;
|
||||||
|
|
||||||
public $css_name;
|
|
||||||
|
|
||||||
/** @var array<int, array{key: string, value: string}> */
|
|
||||||
public array $css_variables = [];
|
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'name' => 'required|string|max:255|unique:device_models,name',
|
'name' => 'required|string|max:255|unique:device_models,name',
|
||||||
'label' => 'required|string|max:255',
|
'label' => 'required|string|max:255',
|
||||||
|
|
@ -72,14 +66,6 @@ new class extends Component
|
||||||
|
|
||||||
public $viewingDeviceModelId;
|
public $viewingDeviceModelId;
|
||||||
|
|
||||||
public function updateFromApi(): void
|
|
||||||
{
|
|
||||||
FetchDeviceModelsJob::dispatchSync();
|
|
||||||
$this->deviceModels = DeviceModel::all();
|
|
||||||
$this->devicePalettes = DevicePalette::all();
|
|
||||||
session()->flash('message', 'Device models updated from API.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
|
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
|
||||||
{
|
{
|
||||||
if ($deviceModelId) {
|
if ($deviceModelId) {
|
||||||
|
|
@ -107,12 +93,10 @@ new class extends Component
|
||||||
$this->offset_y = $deviceModel->offset_y;
|
$this->offset_y = $deviceModel->offset_y;
|
||||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||||
$this->palette_id = $deviceModel->palette_id;
|
$this->palette_id = $deviceModel->palette_id;
|
||||||
$this->css_name = $deviceModel->css_name;
|
|
||||||
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
|
|
||||||
} else {
|
} else {
|
||||||
$this->editingDeviceModelId = null;
|
$this->editingDeviceModelId = null;
|
||||||
$this->viewingDeviceModelId = null;
|
$this->viewingDeviceModelId = null;
|
||||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables']);
|
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']);
|
||||||
$this->mime_type = 'image/png';
|
$this->mime_type = 'image/png';
|
||||||
$this->scale_factor = 1.0;
|
$this->scale_factor = 1.0;
|
||||||
$this->rotation = 0;
|
$this->rotation = 0;
|
||||||
|
|
@ -138,10 +122,6 @@ new class extends Component
|
||||||
'offset_y' => 'required|integer',
|
'offset_y' => 'required|integer',
|
||||||
'published_at' => 'nullable|date',
|
'published_at' => 'nullable|date',
|
||||||
'palette_id' => 'nullable|exists:device_palettes,id',
|
'palette_id' => 'nullable|exists:device_palettes,id',
|
||||||
'css_name' => 'nullable|string|max:255',
|
|
||||||
'css_variables' => 'nullable|array',
|
|
||||||
'css_variables.*.key' => 'nullable|string|max:255',
|
|
||||||
'css_variables.*.value' => 'nullable|string|max:500',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->editingDeviceModelId) {
|
if ($this->editingDeviceModelId) {
|
||||||
|
|
@ -169,8 +149,6 @@ new class extends Component
|
||||||
'offset_y' => $this->offset_y,
|
'offset_y' => $this->offset_y,
|
||||||
'published_at' => $this->published_at,
|
'published_at' => $this->published_at,
|
||||||
'palette_id' => $this->palette_id ?: null,
|
'palette_id' => $this->palette_id ?: null,
|
||||||
'css_name' => $this->css_name ?: null,
|
|
||||||
'css_variables' => $this->normalizeCssVariables(),
|
|
||||||
]);
|
]);
|
||||||
$message = 'Device model updated successfully.';
|
$message = 'Device model updated successfully.';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -189,14 +167,12 @@ new class extends Component
|
||||||
'offset_y' => $this->offset_y,
|
'offset_y' => $this->offset_y,
|
||||||
'published_at' => $this->published_at,
|
'published_at' => $this->published_at,
|
||||||
'palette_id' => $this->palette_id ?: null,
|
'palette_id' => $this->palette_id ?: null,
|
||||||
'css_name' => $this->css_name ?: null,
|
|
||||||
'css_variables' => $this->normalizeCssVariables(),
|
|
||||||
'source' => 'manual',
|
'source' => 'manual',
|
||||||
]);
|
]);
|
||||||
$message = 'Device model created successfully.';
|
$message = 'Device model created successfully.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables', 'editingDeviceModelId', 'viewingDeviceModelId']);
|
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']);
|
||||||
Flux::modal('device-model-modal')->close();
|
Flux::modal('device-model-modal')->close();
|
||||||
|
|
||||||
$this->deviceModels = DeviceModel::all();
|
$this->deviceModels = DeviceModel::all();
|
||||||
|
|
@ -232,38 +208,9 @@ new class extends Component
|
||||||
$this->offset_y = $deviceModel->offset_y;
|
$this->offset_y = $deviceModel->offset_y;
|
||||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||||
$this->palette_id = $deviceModel->palette_id;
|
$this->palette_id = $deviceModel->palette_id;
|
||||||
$this->css_name = $deviceModel->css_name;
|
|
||||||
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
|
|
||||||
|
|
||||||
$this->js('Flux.modal("device-model-modal").show()');
|
$this->js('Flux.modal("device-model-modal").show()');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addCssVariable(): void
|
|
||||||
{
|
|
||||||
$this->css_variables = array_merge($this->css_variables, [['key' => '', 'value' => '']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeCssVariable(int $index): void
|
|
||||||
{
|
|
||||||
$vars = $this->css_variables;
|
|
||||||
array_splice($vars, $index, 1);
|
|
||||||
$this->css_variables = array_values($vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>|null
|
|
||||||
*/
|
|
||||||
private function normalizeCssVariables(): ?array
|
|
||||||
{
|
|
||||||
$pairs = collect($this->css_variables)
|
|
||||||
->filter(fn (array $p): bool => trim($p['key'] ?? '') !== '');
|
|
||||||
|
|
||||||
if ($pairs->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pairs->mapWithKeys(fn (array $p): array => [$p['key'] => $p['value'] ?? ''])->all();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
@ -282,17 +229,9 @@ new class extends Component
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
</div>
|
</div>
|
||||||
<flux:button.group>
|
|
||||||
<flux:modal.trigger name="device-model-modal">
|
<flux:modal.trigger name="device-model-modal">
|
||||||
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
|
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:dropdown>
|
|
||||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
|
||||||
<flux:menu>
|
|
||||||
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from Models API</flux:menu.item>
|
|
||||||
</flux:menu>
|
|
||||||
</flux:dropdown>
|
|
||||||
</flux:button.group>
|
|
||||||
</div>
|
</div>
|
||||||
@if (session()->has('message'))
|
@if (session()->has('message'))
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
@ -388,40 +327,6 @@ new class extends Component
|
||||||
</flux:select>
|
</flux:select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<flux:input label="CSS Model Identifier" wire:model="css_name" id="css_name" class="block mt-1 w-full" type="text"
|
|
||||||
name="css_name" :disabled="(bool) $viewingDeviceModelId"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<flux:heading size="sm" class="mb-2">CSS Variables</flux:heading>
|
|
||||||
@if ($viewingDeviceModelId)
|
|
||||||
@if (count($css_variables) > 0)
|
|
||||||
<dl class="space-y-1.5 text-sm">
|
|
||||||
@foreach ($css_variables as $var)
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="font-medium text-zinc-600 dark:text-zinc-400 min-w-[120px]">{{ $var['key'] }}</dt>
|
|
||||||
<dd class="text-zinc-800 dark:text-zinc-200">{{ $var['value'] }}</dd>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</dl>
|
|
||||||
@else
|
|
||||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">No CSS variables</p>
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
<div class="space-y-3">
|
|
||||||
@foreach ($css_variables as $index => $var)
|
|
||||||
<div class="flex gap-2 items-start" wire:key="css-var-{{ $index }}">
|
|
||||||
<flux:input wire:model="css_variables.{{ $index }}.key" placeholder="e.g. --screen-w" class="flex-1 min-w-0" type="text"/>
|
|
||||||
<flux:input wire:model="css_variables.{{ $index }}.value" placeholder="e.g. 800px" class="flex-1 min-w-0" type="text"/>
|
|
||||||
<flux:button type="button" wire:click="removeCssVariable({{ $index }})" icon="trash" variant="ghost" iconVariant="outline"/>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
<flux:button type="button" wire:click="addCssVariable" variant="ghost" icon="plus" size="sm">Add variable</flux:button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!$viewingDeviceModelId)
|
@if (!$viewingDeviceModelId)
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<flux:spacer/>
|
<flux:spacer/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\FetchDeviceModelsJob;
|
|
||||||
use App\Models\DevicePalette;
|
use App\Models\DevicePalette;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -59,13 +58,6 @@ new class extends Component
|
||||||
|
|
||||||
public $viewingDevicePaletteId;
|
public $viewingDevicePaletteId;
|
||||||
|
|
||||||
public function updateFromApi(): void
|
|
||||||
{
|
|
||||||
FetchDeviceModelsJob::dispatchSync();
|
|
||||||
$this->devicePalettes = DevicePalette::all();
|
|
||||||
session()->flash('message', 'Device palettes updated from API.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
|
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
|
||||||
{
|
{
|
||||||
if ($devicePaletteId) {
|
if ($devicePaletteId) {
|
||||||
|
|
@ -210,17 +202,9 @@ new class extends Component
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
</div>
|
</div>
|
||||||
<flux:button.group>
|
|
||||||
<flux:modal.trigger name="device-palette-modal">
|
<flux:modal.trigger name="device-palette-modal">
|
||||||
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
|
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:dropdown>
|
|
||||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
|
||||||
<flux:menu>
|
|
||||||
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from API</flux:menu.item>
|
|
||||||
</flux:menu>
|
|
||||||
</flux:dropdown>
|
|
||||||
</flux:button.group>
|
|
||||||
</div>
|
</div>
|
||||||
@if (session()->has('message'))
|
@if (session()->has('message'))
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,6 @@ new class extends Component
|
||||||
|
|
||||||
public $device_model_id;
|
public $device_model_id;
|
||||||
|
|
||||||
public $is_mirror = false;
|
|
||||||
|
|
||||||
public $mirror_device_id = null;
|
|
||||||
|
|
||||||
// Signal to device to use high compatibility approaches when redrawing content
|
|
||||||
public $maximum_compatibility = false;
|
|
||||||
|
|
||||||
// Sleep mode and special function
|
// Sleep mode and special function
|
||||||
public $sleep_mode_enabled = false;
|
public $sleep_mode_enabled = false;
|
||||||
|
|
||||||
|
|
@ -88,7 +81,6 @@ new class extends Component
|
||||||
$this->rotate = $device->rotate;
|
$this->rotate = $device->rotate;
|
||||||
$this->image_format = $device->image_format;
|
$this->image_format = $device->image_format;
|
||||||
$this->device_model_id = $device->device_model_id;
|
$this->device_model_id = $device->device_model_id;
|
||||||
$this->maximum_compatibility = $device->maximum_compatibility;
|
|
||||||
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
|
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
|
||||||
// Put TRMNL models at the top, then sort alphabetically within each group
|
// Put TRMNL models at the top, then sort alphabetically within each group
|
||||||
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
|
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
|
||||||
|
|
@ -102,8 +94,6 @@ new class extends Component
|
||||||
$this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i');
|
$this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i');
|
||||||
$this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i');
|
$this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i');
|
||||||
$this->special_function = $device->special_function;
|
$this->special_function = $device->special_function;
|
||||||
$this->is_mirror = $device->mirror_device_id !== null;
|
|
||||||
$this->mirror_device_id = $device->mirror_device_id;
|
|
||||||
|
|
||||||
return view('livewire.devices.configure', [
|
return view('livewire.devices.configure', [
|
||||||
'image' => ($current_image_uuid) ? url($current_image_path) : null,
|
'image' => ($current_image_uuid) ? url($current_image_path) : null,
|
||||||
|
|
@ -151,21 +141,12 @@ new class extends Component
|
||||||
'rotate' => 'required|integer|min:0|max:359',
|
'rotate' => 'required|integer|min:0|max:359',
|
||||||
'image_format' => 'required|string',
|
'image_format' => 'required|string',
|
||||||
'device_model_id' => 'nullable|exists:device_models,id',
|
'device_model_id' => 'nullable|exists:device_models,id',
|
||||||
'mirror_device_id' => 'required_if:is_mirror,true',
|
|
||||||
'maximum_compatibility' => 'boolean',
|
|
||||||
'sleep_mode_enabled' => 'boolean',
|
'sleep_mode_enabled' => 'boolean',
|
||||||
'sleep_mode_from' => 'nullable|date_format:H:i',
|
'sleep_mode_from' => 'nullable|date_format:H:i',
|
||||||
'sleep_mode_to' => 'nullable|date_format:H:i',
|
'sleep_mode_to' => 'nullable|date_format:H:i',
|
||||||
'special_function' => 'nullable|string',
|
'special_function' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->is_mirror) {
|
|
||||||
$mirrorDevice = auth()->user()->devices()->find($this->mirror_device_id);
|
|
||||||
abort_unless($mirrorDevice, 403, 'Invalid mirror device selected');
|
|
||||||
abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device');
|
|
||||||
abort_if((int) $this->mirror_device_id === (int) $this->device->id, 403, 'Device cannot mirror itself');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert empty string to null for custom selection
|
// Convert empty string to null for custom selection
|
||||||
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
|
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
|
||||||
|
|
||||||
|
|
@ -179,8 +160,6 @@ new class extends Component
|
||||||
'rotate' => $this->rotate,
|
'rotate' => $this->rotate,
|
||||||
'image_format' => $this->image_format,
|
'image_format' => $this->image_format,
|
||||||
'device_model_id' => $deviceModelId,
|
'device_model_id' => $deviceModelId,
|
||||||
'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null,
|
|
||||||
'maximum_compatibility' => $this->maximum_compatibility,
|
|
||||||
'sleep_mode_enabled' => $this->sleep_mode_enabled,
|
'sleep_mode_enabled' => $this->sleep_mode_enabled,
|
||||||
'sleep_mode_from' => $this->sleep_mode_from,
|
'sleep_mode_from' => $this->sleep_mode_from,
|
||||||
'sleep_mode_to' => $this->sleep_mode_to,
|
'sleep_mode_to' => $this->sleep_mode_to,
|
||||||
|
|
@ -448,20 +427,6 @@ new class extends Component
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
|
||||||
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device"/>
|
|
||||||
@if($is_mirror)
|
|
||||||
<flux:select wire:model="mirror_device_id" label="Select Device to Mirror">
|
|
||||||
<flux:select.option value="">Select a device</flux:select.option>
|
|
||||||
@foreach(auth()->user()->devices->where('mirror_device_id', null)->where('id', '!=', $device->id) as $mirrorOption)
|
|
||||||
<flux:select.option value="{{ $mirrorOption->id }}">
|
|
||||||
{{ $mirrorOption->name }} ({{ $mirrorOption->friendly_id }})
|
|
||||||
</flux:select.option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<flux:checkbox wire:model="maximum_compatibility" label="Maximum Compatibility" description="Resolves display issues caused by certain e-ink driver chips. Disables fast refresh. TRMNL Firmware 1.6.0+ required." />
|
|
||||||
|
|
||||||
@if(empty($device_model_id))
|
@if(empty($device_model_id))
|
||||||
<flux:separator class="my-4" text="Advanced Device Settings" />
|
<flux:separator class="my-4" text="Advanced Device Settings" />
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
|
@ -559,7 +524,7 @@ new class extends Component
|
||||||
|
|
||||||
<flux:modal name="mirror-url" class="md:w-96">
|
<flux:modal name="mirror-url" class="md:w-96">
|
||||||
@php
|
@php
|
||||||
$mirrorUrl = url('/mirror/index.html') . '?api_key=' . urlencode($device->api_key);
|
$mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -822,3 +787,4 @@ new class extends Component
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -55,22 +54,12 @@ new class extends Component
|
||||||
/**
|
/**
|
||||||
* Triggered by @close on the modal to discard any typed but unsaved changes
|
* Triggered by @close on the modal to discard any typed but unsaved changes
|
||||||
*/
|
*/
|
||||||
public int $resetIndex = 0;
|
public int $resetIndex = 0; // Add this property
|
||||||
|
|
||||||
/**
|
|
||||||
* When recipe settings (or this modal) save, reload so Configuration Fields form stays in sync.
|
|
||||||
*/
|
|
||||||
#[On('config-updated')]
|
|
||||||
public function refreshFromParent(): void
|
|
||||||
{
|
|
||||||
$this->loadData();
|
|
||||||
$this->resetIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetForm(): void
|
public function resetForm(): void
|
||||||
{
|
{
|
||||||
$this->loadData();
|
$this->loadData();
|
||||||
++$this->resetIndex;
|
++$this->resetIndex; // Increment to force DOM refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveConfiguration()
|
public function saveConfiguration()
|
||||||
|
|
|
||||||
|
|
@ -258,13 +258,14 @@ new class extends Component
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:heading size="sm">Limitations</flux:heading>
|
<flux:heading size="sm">Limitations</flux:heading>
|
||||||
<ul class="list-disc pl-5 mt-2">
|
<ul class="list-disc pl-5 mt-2">
|
||||||
|
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
|
||||||
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
|
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
|
||||||
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
|
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
|
||||||
{{-- <ul class="list-disc pl-5 mt-2">--}}
|
{{-- <ul class="list-disc pl-5 mt-2">--}}
|
||||||
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
|
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
|
||||||
{{-- </ul>--}}
|
{{-- </ul>--}}
|
||||||
</ul>
|
</ul>
|
||||||
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
|
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form wire:submit="importZip">
|
<form wire:submit="importZip">
|
||||||
|
|
@ -311,11 +312,12 @@ new class extends Component
|
||||||
<flux:callout class="mb-4 mt-4" color="yellow">
|
<flux:callout class="mb-4 mt-4" color="yellow">
|
||||||
<flux:heading size="sm">Limitations</flux:heading>
|
<flux:heading size="sm">Limitations</flux:heading>
|
||||||
<ul class="list-disc pl-5 mt-2">
|
<ul class="list-disc pl-5 mt-2">
|
||||||
|
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
|
||||||
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
|
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
|
||||||
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
|
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
|
||||||
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
|
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
|
||||||
</ul>
|
</ul>
|
||||||
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
|
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
</div>
|
</div>
|
||||||
<livewire:catalog.trmnl />
|
<livewire:catalog.trmnl />
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ new class extends Component
|
||||||
<x-trmnl::view>
|
<x-trmnl::view>
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::richtext gapSize="large" align="center">
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
<x-trmnl::title>LaraPaper</x-trmnl::title>
|
<x-trmnl::title>TRMNL BYOS Laravel</x-trmnl::title>
|
||||||
<x-trmnl::content>“This screen was rendered by BYOS LaraPaper”</x-trmnl::content>
|
<x-trmnl::content>“This screen was rendered by BYOS Laravel”</x-trmnl::content>
|
||||||
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
|
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
|
||||||
</x-trmnl::richtext>
|
</x-trmnl::richtext>
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,6 @@ new class extends Component
|
||||||
|
|
||||||
public string $preview_size = 'full';
|
public string $preview_size = 'full';
|
||||||
|
|
||||||
public array $markup_layouts = [];
|
|
||||||
|
|
||||||
public array $active_tabs = [];
|
|
||||||
|
|
||||||
public string $active_tab = 'full';
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
@ -97,24 +91,7 @@ new class extends Component
|
||||||
$this->view_content = null;
|
$this->view_content = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Initialize layout markups from plugin columns
|
$this->markup_code = $this->plugin->render_markup;
|
||||||
$this->markup_layouts = [
|
|
||||||
'full' => $this->plugin->render_markup ?? '',
|
|
||||||
'half_horizontal' => $this->plugin->render_markup_half_horizontal ?? '',
|
|
||||||
'half_vertical' => $this->plugin->render_markup_half_vertical ?? '',
|
|
||||||
'quadrant' => $this->plugin->render_markup_quadrant ?? '',
|
|
||||||
'shared' => $this->plugin->render_markup_shared ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Set active tabs based on which layouts have content
|
|
||||||
$this->active_tabs = ['full']; // Full is always active
|
|
||||||
foreach (['half_horizontal', 'half_vertical', 'quadrant', 'shared'] as $layout) {
|
|
||||||
if (! empty($this->markup_layouts[$layout])) {
|
|
||||||
$this->active_tabs[] = $layout;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->markup_code = $this->markup_layouts['full'];
|
|
||||||
$this->markup_language = $this->plugin->markup_language ?? 'blade';
|
$this->markup_language = $this->plugin->markup_language ?? 'blade';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,108 +125,12 @@ new class extends Component
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
// Update markup_code for the active tab
|
|
||||||
if (isset($this->markup_layouts[$this->active_tab])) {
|
|
||||||
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save all layout markups to respective columns
|
|
||||||
$this->plugin->update([
|
$this->plugin->update([
|
||||||
'render_markup' => $this->markup_layouts['full'] ?? null,
|
'render_markup' => $this->markup_code ?? null,
|
||||||
'render_markup_half_horizontal' => ! empty($this->markup_layouts['half_horizontal']) ? $this->markup_layouts['half_horizontal'] : null,
|
|
||||||
'render_markup_half_vertical' => ! empty($this->markup_layouts['half_vertical']) ? $this->markup_layouts['half_vertical'] : null,
|
|
||||||
'render_markup_quadrant' => ! empty($this->markup_layouts['quadrant']) ? $this->markup_layouts['quadrant'] : null,
|
|
||||||
'render_markup_shared' => ! empty($this->markup_layouts['shared']) ? $this->markup_layouts['shared'] : null,
|
|
||||||
'markup_language' => $this->markup_language ?? null,
|
'markup_language' => $this->markup_language ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addLayoutTab(string $layout): void
|
|
||||||
{
|
|
||||||
if (! in_array($layout, $this->active_tabs, true)) {
|
|
||||||
$this->active_tabs[] = $layout;
|
|
||||||
if (! isset($this->markup_layouts[$layout])) {
|
|
||||||
$this->markup_layouts[$layout] = '';
|
|
||||||
}
|
|
||||||
$this->switchTab($layout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeLayoutTab(string $layout): void
|
|
||||||
{
|
|
||||||
if ($layout !== 'full') {
|
|
||||||
$this->active_tabs = array_values(array_filter($this->active_tabs, fn ($tab) => $tab !== $layout));
|
|
||||||
if (isset($this->markup_layouts[$layout])) {
|
|
||||||
$this->markup_layouts[$layout] = '';
|
|
||||||
}
|
|
||||||
if ($this->active_tab === $layout) {
|
|
||||||
$this->active_tab = 'full';
|
|
||||||
$this->markup_code = $this->markup_layouts['full'] ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function switchTab(string $layout): void
|
|
||||||
{
|
|
||||||
if (in_array($layout, $this->active_tabs, true)) {
|
|
||||||
// Save current tab's content before switching
|
|
||||||
if (isset($this->markup_layouts[$this->active_tab])) {
|
|
||||||
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->active_tab = $layout;
|
|
||||||
$this->markup_code = $this->markup_layouts[$layout] ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleLayoutTab(string $layout): void
|
|
||||||
{
|
|
||||||
if ($layout === 'full') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($layout, $this->active_tabs, true)) {
|
|
||||||
$this->removeLayoutTab($layout);
|
|
||||||
} else {
|
|
||||||
$this->addLayoutTab($layout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAvailableLayouts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'half_horizontal' => 'Half Horizontal',
|
|
||||||
'half_vertical' => 'Half Vertical',
|
|
||||||
'quadrant' => 'Quadrant',
|
|
||||||
'shared' => 'Shared',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLayoutLabel(string $layout): string
|
|
||||||
{
|
|
||||||
return match ($layout) {
|
|
||||||
'full' => $this->getFullTabLabel(),
|
|
||||||
'half_horizontal' => 'Half Horizontal',
|
|
||||||
'half_vertical' => 'Half Vertical',
|
|
||||||
'quadrant' => 'Quadrant',
|
|
||||||
'shared' => 'Shared',
|
|
||||||
default => ucfirst($layout),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFullTabLabel(): string
|
|
||||||
{
|
|
||||||
// Return "Full" if any layout-specific markup exists, otherwise "Responsive"
|
|
||||||
if (! empty($this->markup_layouts['half_horizontal'])
|
|
||||||
|| ! empty($this->markup_layouts['half_vertical'])
|
|
||||||
|| ! empty($this->markup_layouts['quadrant'])) {
|
|
||||||
return 'Full';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Responsive';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected array $rules = [
|
protected array $rules = [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'data_stale_minutes' => 'required|integer|min:1',
|
'data_stale_minutes' => 'required|integer|min:1',
|
||||||
|
|
@ -569,10 +450,11 @@ HTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[On('config-updated')]
|
#[On('config-updated')]
|
||||||
public function refreshPlugin(): void
|
public function refreshPlugin()
|
||||||
{
|
{
|
||||||
|
// This pulls the fresh 'configuration' from the DB
|
||||||
|
// and re-triggers the @if check in the Blade template
|
||||||
$this->plugin = $this->plugin->fresh();
|
$this->plugin = $this->plugin->fresh();
|
||||||
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laravel Livewire computed property: access with $this->parsed_urls
|
// Laravel Livewire computed property: access with $this->parsed_urls
|
||||||
|
|
@ -1136,42 +1018,9 @@ HTML;
|
||||||
@if(!$plugin->render_markup_view)
|
@if(!$plugin->render_markup_view)
|
||||||
<form wire:submit="saveMarkup">
|
<form wire:submit="saveMarkup">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
@foreach($active_tabs as $tab)
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
wire:click="switchTab('{{ $tab }}')"
|
|
||||||
class="tab-button {{ $active_tab === $tab ? 'is-active' : '' }}"
|
|
||||||
wire:key="tab-{{ $tab }}"
|
|
||||||
>
|
|
||||||
{{ $this->getLayoutLabel($tab) }}
|
|
||||||
</button>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
<flux:dropdown>
|
|
||||||
<flux:button icon="plus" variant="ghost" size="sm" class="m-0.5"></flux:button>
|
|
||||||
<flux:menu>
|
|
||||||
@foreach($this->getAvailableLayouts() as $layout => $label)
|
|
||||||
<flux:menu.item wire:click="toggleLayoutTab('{{ $layout }}')">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@if(in_array($layout, $active_tabs, true))
|
|
||||||
<flux:icon.check class="size-4" />
|
|
||||||
@else
|
|
||||||
<span class="inline-block w-4 h-4"></span>
|
|
||||||
@endif
|
|
||||||
<span>{{ $label }}</span>
|
|
||||||
</div>
|
|
||||||
</flux:menu.item>
|
|
||||||
@endforeach
|
|
||||||
</flux:menu>
|
|
||||||
</flux:dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
@php
|
@php
|
||||||
$textareaId = 'code-' . $plugin->id;
|
$textareaId = 'code-' . uniqid();
|
||||||
@endphp
|
@endphp
|
||||||
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
|
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
|
||||||
<flux:textarea
|
<flux:textarea
|
||||||
|
|
@ -1184,7 +1033,7 @@ HTML;
|
||||||
<div
|
<div
|
||||||
x-data="codeEditorFormComponent({
|
x-data="codeEditorFormComponent({
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
language: @js($markup_language === 'liquid' ? 'liquid' : 'html'),
|
language: 'liquid',
|
||||||
state: $wire.entangle('markup_code'),
|
state: $wire.entangle('markup_code'),
|
||||||
textareaId: @js($textareaId)
|
textareaId: @js($textareaId)
|
||||||
})"
|
})"
|
||||||
|
|
@ -1203,8 +1052,7 @@ HTML;
|
||||||
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
|
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Symfony\Component\Yaml\Exception\ParseException;
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This component contains the TRMNL Plugin Settings modal.
|
* This component contains the TRMNL Plugin Settings modal
|
||||||
*/
|
*/
|
||||||
new class extends Component
|
new class extends Component
|
||||||
{
|
{
|
||||||
|
|
@ -19,21 +17,16 @@ new class extends Component
|
||||||
|
|
||||||
public bool $alias = false;
|
public bool $alias = false;
|
||||||
|
|
||||||
public bool $use_trmnl_liquid_renderer = false;
|
|
||||||
|
|
||||||
public string $configurationTemplateYaml = '';
|
|
||||||
|
|
||||||
public int $resetIndex = 0;
|
public int $resetIndex = 0;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->resetErrorBag();
|
$this->resetErrorBag();
|
||||||
|
// Reload data
|
||||||
$this->plugin = $this->plugin->fresh();
|
$this->plugin = $this->plugin->fresh();
|
||||||
$this->trmnlp_id = $this->plugin->trmnlp_id;
|
$this->trmnlp_id = $this->plugin->trmnlp_id;
|
||||||
$this->uuid = $this->plugin->uuid;
|
$this->uuid = $this->plugin->uuid;
|
||||||
$this->alias = $this->plugin->alias ?? false;
|
$this->alias = $this->plugin->alias ?? false;
|
||||||
$this->use_trmnl_liquid_renderer = $this->plugin->preferred_renderer === 'trmnl-liquid';
|
|
||||||
$this->configurationTemplateYaml = $this->plugin->getCustomFieldsEditorYaml();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveTrmnlpId(): void
|
public function saveTrmnlpId(): void
|
||||||
|
|
@ -50,47 +43,13 @@ new class extends Component
|
||||||
->ignore($this->plugin->id),
|
->ignore($this->plugin->id),
|
||||||
],
|
],
|
||||||
'alias' => 'boolean',
|
'alias' => 'boolean',
|
||||||
'use_trmnl_liquid_renderer' => 'boolean',
|
|
||||||
'configurationTemplateYaml' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
|
||||||
if ($value === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$parsed = Yaml::parse($value);
|
|
||||||
if (! is_array($parsed)) {
|
|
||||||
$fail('The configuration must be valid YAML and evaluate to an object/array.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Plugin::validateCustomFieldsList($parsed);
|
|
||||||
} catch (ParseException) {
|
|
||||||
$fail('The configuration must be valid YAML.');
|
|
||||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
||||||
foreach ($e->errors() as $messages) {
|
|
||||||
foreach ($messages as $message) {
|
|
||||||
$fail($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$configurationTemplate = Plugin::configurationTemplateFromCustomFieldsYaml(
|
|
||||||
$this->configurationTemplateYaml,
|
|
||||||
$this->plugin->configuration_template
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->plugin->update([
|
$this->plugin->update([
|
||||||
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
|
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
|
||||||
'alias' => $this->alias,
|
'alias' => $this->alias,
|
||||||
'preferred_renderer' => $this->use_trmnl_liquid_renderer ? 'trmnl-liquid' : null,
|
|
||||||
'configuration_template' => $configurationTemplate,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('config-updated');
|
|
||||||
Flux::modal('trmnlp-settings')->close();
|
Flux::modal('trmnlp-settings')->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +59,7 @@ new class extends Component
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<flux:modal name="trmnlp-settings" class="min-w-[600px] max-w-2xl space-y-6">
|
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
|
||||||
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
|
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">Recipe Settings</flux:heading>
|
<flux:heading size="lg">Recipe Settings</flux:heading>
|
||||||
|
|
@ -124,53 +83,6 @@ new class extends Component
|
||||||
<flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description>
|
<flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
@if(config('services.trmnl.liquid_enabled') && $plugin->markup_language === 'liquid')
|
|
||||||
<flux:field>
|
|
||||||
<flux:checkbox
|
|
||||||
wire:model.live="use_trmnl_liquid_renderer"
|
|
||||||
label="Use trmnl-liquid renderer"
|
|
||||||
/>
|
|
||||||
<flux:description>trmnl-liquid is a Ruby-based renderer that matches the Core service’s Liquid behavior for better compatibility.</flux:description>
|
|
||||||
</flux:field>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<flux:field>
|
|
||||||
<flux:label>Configuration template</flux:label>
|
|
||||||
<flux:description>
|
|
||||||
Build forms visually in the <a href="https://usetrmnl.github.io/trmnl-form-builder/" target="_blank" rel="noopener noreferrer">TRMNL YML Form Builder</a>.
|
|
||||||
Check the <a href="https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder" target="_blank" rel="noopener noreferrer">docs</a> for more information.
|
|
||||||
</flux:description>
|
|
||||||
@php
|
|
||||||
$configTemplateTextareaId = 'config-template-' . uniqid();
|
|
||||||
@endphp
|
|
||||||
<flux:textarea
|
|
||||||
wire:model="configurationTemplateYaml"
|
|
||||||
id="{{ $configTemplateTextareaId }}"
|
|
||||||
placeholder="[]"
|
|
||||||
rows="12"
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
x-data="codeEditorFormComponent({
|
|
||||||
isDisabled: false,
|
|
||||||
language: 'yaml',
|
|
||||||
state: $wire.entangle('configurationTemplateYaml'),
|
|
||||||
textareaId: @js($configTemplateTextareaId)
|
|
||||||
})"
|
|
||||||
wire:ignore
|
|
||||||
wire:key="cm-{{ $configTemplateTextareaId }}"
|
|
||||||
class="min-h-[200px] h-[300px] overflow-hidden resize-y"
|
|
||||||
>
|
|
||||||
<div x-show="isLoading" class="flex items-center justify-center h-full">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<flux:icon.loading />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
|
|
||||||
</div>
|
|
||||||
<flux:error name="configurationTemplateYaml" />
|
|
||||||
</flux:field>
|
|
||||||
|
|
||||||
@if($alias)
|
@if($alias)
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>Alias URL</flux:label>
|
<flux:label>Alias URL</flux:label>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ new class extends Component {}
|
||||||
<div class="mt-3 flex items-center justify-start gap-2">
|
<div class="mt-3 flex items-center justify-start gap-2">
|
||||||
<flux:input value="laravel-trmnl" readonly copyable class="max-w-42"/>
|
<flux:input value="laravel-trmnl" readonly copyable class="max-w-42"/>
|
||||||
<flux:button class="w-42"
|
<flux:button class="w-42"
|
||||||
href="{{ config('services.trmnl.base_url') }}?ref=laravel-trmnl"
|
href="https://usetrmnl.com/?ref=laravel-trmnl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
icon:trailing="arrow-up-right">{{ __('Referral link') }}</flux:button>
|
icon:trailing="arrow-up-right">{{ __('Referral link') }}</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ new class extends Component
|
||||||
<flux:callout icon="check-circle" variant="success">
|
<flux:callout icon="check-circle" variant="success">
|
||||||
<flux:callout.heading>Up to Date</flux:callout.heading>
|
<flux:callout.heading>Up to Date</flux:callout.heading>
|
||||||
<flux:callout.text>
|
<flux:callout.text>
|
||||||
You are running the latest version.
|
You are running the latest version {{ $latestVersion }}.
|
||||||
</flux:callout.text>
|
</flux:callout.text>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<title>{{ $title ?? 'LaraPaper' }}</title>
|
<title>{{ $title ?? 'TRMNL BYOS Laravel' }}</title>
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
|
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<x-trmnl::label variant="inverted">{{ Str::limit($event['location'] ?? '—',100) }}</x-trmnl::label>
|
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<div class="grid" style="gap: 9px;">
|
<div class="grid" style="gap: 9px;">
|
||||||
<div class="row row--center col--span-3 col--end">
|
<div class="row row--center col--span-3 col--end">
|
||||||
<img class="weather-image" style="max-height: 150px; margin:auto;"
|
<img class="weather-image" style="max-height: 150px; margin:auto;"
|
||||||
src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg">
|
src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg">
|
||||||
</div>
|
</div>
|
||||||
<div class="col col--span-3 col--end">
|
<div class="col col--span-3 col--end">
|
||||||
<div class="item h--full">
|
<div class="item h--full">
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg"> --}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg"> --}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span class="value value--small">{{ $weatherEntity['attributes']['wind_speed'] }} {{ $weatherEntity['attributes']['wind_speed_unit'] }}</span>
|
<span class="value value--small">{{ $weatherEntity['attributes']['wind_speed'] }} {{ $weatherEntity['attributes']['wind_speed_unit'] }}</span>
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-raindrops.svg"> --}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-raindrops.svg"> --}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span class="value value--small">{{ $weatherEntity['attributes']['humidity'] }}%</span>
|
<span class="value value--small">{{ $weatherEntity['attributes']['humidity'] }}%</span>
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-day-sunny.svg"> --}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-day-sunny.svg"> --}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span class="value value--xsmall">{{ Str::title($weatherEntity['state']) }}</span>
|
<span class="value value--xsmall">{{ Str::title($weatherEntity['state']) }}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script src="https://trmnl.com/js/highcharts/12.3.0/highcharts.js"></script>
|
<script src="https://usetrmnl.com/js/highcharts/12.3.0/highcharts.js"></script>
|
||||||
<script src="https://trmnl.com/js/chartkick/5.0.1/chartkick.min.js"></script>
|
<script src="https://usetrmnl.com/js/chartkick/5.0.1/chartkick.min.js"></script>
|
||||||
|
|
||||||
<div class="view view--{{ size }}">
|
<div class="view view--{{ size }}">
|
||||||
<div class="layout layout--col gap--space-between">
|
<div class="layout layout--col gap--space-between">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="grid" style="gap: 9px;">
|
<div class="grid" style="gap: 9px;">
|
||||||
<div class="row row--center col--span-3 col--end">
|
<div class="row row--center col--span-3 col--end">
|
||||||
<img class="weather-image" style="max-height: 150px; margin:auto;"
|
<img class="weather-image" style="max-height: 150px; margin:auto;"
|
||||||
src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg">
|
src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg">
|
||||||
</div>
|
</div>
|
||||||
<div class="col col--span-3 col--center">
|
<div class="col col--span-3 col--center">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-thermometer.svg">--}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-thermometer.svg">--}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span
|
<span
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-raindrops.svg">--}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-raindrops.svg">--}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span class="value value--small">{{Arr::get($data, 'properties.timeseries.0.data.instant.details.relative_humidity', 'N/A')}}%</span>
|
<span class="value value--small">{{Arr::get($data, 'properties.timeseries.0.data.instant.details.relative_humidity', 'N/A')}}%</span>
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="meta"></div>
|
<div class="meta"></div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
{{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-day-sunny.svg">--}}
|
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-day-sunny.svg">--}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{{--@dump($data)--}}
|
{{--@dump($data)--}}
|
||||||
@props(['size' => 'full'])
|
@props(['size' => 'full'])
|
||||||
<x-trmnl::view size="{{ $size }}">
|
<x-trmnl::view size="{{ $size }}">
|
||||||
|
<x-trmnl::layout>
|
||||||
<x-trmnl::layout class="layout--col">
|
<x-trmnl::layout class="layout--col">
|
||||||
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
|
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
|
||||||
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
|
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
|
||||||
|
|
@ -9,6 +10,7 @@
|
||||||
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
|
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
|
||||||
@endif
|
@endif
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
|
</x-trmnl::layout>
|
||||||
|
|
||||||
<div class="title_bar">
|
<div class="title_bar">
|
||||||
<img class="image" src="https://img.icons8.com/books"/>
|
<img class="image" src="https://img.icons8.com/books"/>
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,18 @@
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
@if(config('app.puppeteer_window_size_strategy') === 'v2')
|
@if(config('app.puppeteer_window_size_strategy') === 'v2')
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
scale-level="{{$scaleLevel}}"
|
scale-level="{{$scaleLevel}}">
|
||||||
:css-variables="$cssVariables">
|
|
||||||
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
||||||
{!! $slot !!}
|
{!! $slot !!}
|
||||||
</x-trmnl::mashup>
|
</x-trmnl::mashup>
|
||||||
</x-trmnl::screen>
|
</x-trmnl::screen>
|
||||||
@else
|
@else
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}">
|
||||||
scale-level="{{$scaleLevel}}"
|
|
||||||
:css-variables="$cssVariables">
|
|
||||||
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
||||||
{!! $slot !!}
|
{!! $slot !!}
|
||||||
</x-trmnl::mashup>
|
</x-trmnl::mashup>
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,16 @@
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
@if(config('app.puppeteer_window_size_strategy') === 'v2')
|
@if(config('app.puppeteer_window_size_strategy') === 'v2')
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
scale-level="{{$scaleLevel}}"
|
scale-level="{{$scaleLevel}}">
|
||||||
:css-variables="$cssVariables">
|
|
||||||
{!! $slot !!}
|
{!! $slot !!}
|
||||||
</x-trmnl::screen>
|
</x-trmnl::screen>
|
||||||
@else
|
@else
|
||||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}">
|
||||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
|
||||||
scale-level="{{$scaleLevel}}"
|
|
||||||
:css-variables="$cssVariables">
|
|
||||||
{!! $slot !!}
|
{!! $slot !!}
|
||||||
</x-trmnl::screen>
|
</x-trmnl::screen>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
@props([
|
@props([
|
||||||
'noBleed' => false,
|
'noBleed' => false,
|
||||||
'darkMode' => false,
|
'darkMode' => false,
|
||||||
'deviceVariant' => 'ogv2',
|
'deviceVariant' => 'og',
|
||||||
'deviceOrientation' => null,
|
'deviceOrientation' => null,
|
||||||
'colorDepth' => '1bit',
|
'colorDepth' => '1bit',
|
||||||
'scaleLevel' => null,
|
'scaleLevel' => null,
|
||||||
'cssVariables' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
@ -19,23 +18,14 @@
|
||||||
href="{{ config('trmnl-blade.framework_css_url') }}">
|
href="{{ config('trmnl-blade.framework_css_url') }}">
|
||||||
@else
|
@else
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ config('services.trmnl.base_url') }}/css/{{ config('trmnl-blade.framework_css_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.css">
|
href="https://usetrmnl.com/css/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.css">
|
||||||
@endif
|
@endif
|
||||||
@if (config('trmnl-blade.framework_js_url'))
|
@if (config('trmnl-blade.framework_js_url'))
|
||||||
<script src="{{ config('trmnl-blade.framework_js_url') }}"></script>
|
<script src="{{ config('trmnl-blade.framework_js_url') }}"></script>
|
||||||
@else
|
@else
|
||||||
<script src="{{ config('services.trmnl.base_url') }}/js/{{ config('trmnl-blade.framework_js_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.js"></script>
|
<script src="https://usetrmnl.com/js/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.js"></script>
|
||||||
@endif
|
@endif
|
||||||
<title>{{ $title ?? config('app.name') }}</title>
|
<title>{{ $title ?? config('app.name') }}</title>
|
||||||
@if(config('app.puppeteer_window_size_strategy') === 'v2' && !empty($cssVariables) && is_array($cssVariables))
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
@foreach($cssVariables as $name => $value)
|
|
||||||
{{ $name }}: {{ $value }};
|
|
||||||
@endforeach
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@endif
|
|
||||||
</head>
|
</head>
|
||||||
<body class="environment trmnl">
|
<body class="environment trmnl">
|
||||||
<div class="screen {{$noBleed ? 'screen--no-bleed' : ''}} {{ $darkMode ? 'dark-mode' : '' }} {{$deviceVariant ? 'screen--' . $deviceVariant : ''}} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : ''}} {{ $colorDepth ? 'screen--' . $colorDepth : ''}} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : ''}}">
|
<div class="screen {{$noBleed ? 'screen--no-bleed' : ''}} {{ $darkMode ? 'dark-mode' : '' }} {{$deviceVariant ? 'screen--' . $deviceVariant : ''}} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : ''}} {{ $colorDepth ? 'screen--' . $colorDepth : ''}} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : ''}}">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<x-layouts::auth.card>
|
<x-layouts::auth.card>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<x-auth-header title="LaraPaper" description="Server is up and running."/>
|
<x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/>
|
||||||
</div>
|
</div>
|
||||||
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden">
|
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden">
|
||||||
@if (Route::has('login'))
|
@if (Route::has('login'))
|
||||||
|
|
@ -32,11 +32,6 @@
|
||||||
@endif
|
@endif
|
||||||
</header>
|
</header>
|
||||||
@auth
|
@auth
|
||||||
@if(config('app.version'))
|
|
||||||
<flux:text class="text-xs">Version: <a href="https://github.com/{{ config('app.github_repo') }}/releases/tag/{{ config('app.version') }}"
|
|
||||||
target="_blank">{{ config('app.version') }}</a>
|
|
||||||
</flux:text>
|
|
||||||
@endif
|
|
||||||
<livewire:update-check />
|
<livewire:update-check />
|
||||||
@endauth
|
@endauth
|
||||||
</x-layouts::auth.card>
|
</x-layouts::auth.card>
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ Route::get('/display', function (Request $request) {
|
||||||
'last_refreshed_at' => now(),
|
'last_refreshed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$batteryPercent = $request->header('battery-percent') ?? $request->header('percent-charged');
|
if ($request->hasHeader('battery-percent')) {
|
||||||
if ($batteryPercent !== null) {
|
$batteryPercent = (int) $request->header('battery-percent');
|
||||||
$batteryPercent = (int) $batteryPercent;
|
|
||||||
$batteryVoltage = $device->calculateVoltageFromPercent($batteryPercent);
|
$batteryVoltage = $device->calculateVoltageFromPercent($batteryPercent);
|
||||||
$device->update([
|
$device->update([
|
||||||
'last_battery_voltage' => $batteryVoltage,
|
'last_battery_voltage' => $batteryVoltage,
|
||||||
|
|
@ -88,8 +87,8 @@ Route::get('/display', function (Request $request) {
|
||||||
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
|
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
|
||||||
$plugin = $playlistItem->plugin;
|
$plugin = $playlistItem->plugin;
|
||||||
|
|
||||||
ImageGenerationService::resetIfNotCacheable($plugin, $device);
|
// Reset cache if Devices with different dimensions exist
|
||||||
$plugin->refresh();
|
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||||
|
|
||||||
// Check and update stale data if needed
|
// Check and update stale data if needed
|
||||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||||
|
|
@ -195,7 +194,6 @@ Route::get('/display', function (Request $request) {
|
||||||
'update_firmware' => $device->update_firmware,
|
'update_firmware' => $device->update_firmware,
|
||||||
'firmware_url' => $device->firmware_url,
|
'firmware_url' => $device->firmware_url,
|
||||||
'special_function' => $device->special_function ?? 'sleep',
|
'special_function' => $device->special_function ?? 'sleep',
|
||||||
'maximum_compatibility' => $device->maximum_compatibility,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config('services.trmnl.image_url_timeout')) {
|
if (config('services.trmnl.image_url_timeout')) {
|
||||||
|
|
@ -699,9 +697,6 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageGenerationService::resetIfNotCacheable($plugin, $deviceModel);
|
|
||||||
$plugin->refresh();
|
|
||||||
|
|
||||||
// Check if we can use cached image (only for og_png and if data is not stale)
|
// Check if we can use cached image (only for og_png and if data is not stale)
|
||||||
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
|
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
|
||||||
|
|
||||||
|
|
@ -747,13 +742,9 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
|
||||||
palette: $deviceModel->palette
|
palette: $deviceModel->palette
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update plugin cache if using og_png (recipes only get metadata for cache comparison)
|
// Update plugin cache if using og_png
|
||||||
if ($deviceModelName === 'og_png') {
|
if ($deviceModelName === 'og_png') {
|
||||||
$update = ['current_image' => $imageUuid];
|
$plugin->update(['current_image' => $imageUuid]);
|
||||||
if ($plugin->plugin_type === 'recipe') {
|
|
||||||
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDeviceModel($deviceModel);
|
|
||||||
}
|
|
||||||
$plugin->update($update);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the generated image
|
// Return the generated image
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 686 B |
|
Before Width: | Height: | Size: 901 B |
|
Before Width: | Height: | Size: 2 KiB |
|
|
@ -45,7 +45,6 @@ test('device can fetch display data with valid credentials', function (): void {
|
||||||
'update_firmware' => false,
|
'update_firmware' => false,
|
||||||
'firmware_url' => null,
|
'firmware_url' => null,
|
||||||
'special_function' => 'sleep',
|
'special_function' => 'sleep',
|
||||||
'maximum_compatibility' => false,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($device->fresh())
|
expect($device->fresh())
|
||||||
|
|
@ -96,27 +95,6 @@ test('display endpoint omits image_url_timeout when not configured', function ()
|
||||||
->assertJsonMissing(['image_url_timeout']);
|
->assertJsonMissing(['image_url_timeout']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('display endpoint includes maximum_compatibility value when true for device', function (): void {
|
|
||||||
$device = Device::factory()->create([
|
|
||||||
'mac_address' => '00:11:22:33:44:55',
|
|
||||||
'api_key' => 'test-api-key',
|
|
||||||
'maximum_compatibility' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->withHeaders([
|
|
||||||
'id' => $device->mac_address,
|
|
||||||
'access-token' => $device->api_key,
|
|
||||||
'rssi' => -70,
|
|
||||||
'battery_voltage' => 3.8,
|
|
||||||
'fw-version' => '1.0.0',
|
|
||||||
])->get('/api/display');
|
|
||||||
|
|
||||||
$response->assertOk()
|
|
||||||
->assertJson([
|
|
||||||
'maximum_compatibility' => true,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new device is auto-assigned to user with auto-assign enabled', function (): void {
|
test('new device is auto-assigned to user with auto-assign enabled', function (): void {
|
||||||
$user = User::factory()->create(['assign_new_devices' => true]);
|
$user = User::factory()->create(['assign_new_devices' => true]);
|
||||||
|
|
||||||
|
|
@ -725,40 +703,6 @@ test('display endpoint updates last_refreshed_at timestamp', function (): void {
|
||||||
->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2);
|
->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('display endpoint accepts battery-percent header and updates device', function (): void {
|
|
||||||
$device = Device::factory()->create([
|
|
||||||
'mac_address' => '00:11:22:33:44:56',
|
|
||||||
'api_key' => 'test-api-key-battery',
|
|
||||||
'last_battery_voltage' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->withHeaders([
|
|
||||||
'id' => $device->mac_address,
|
|
||||||
'access-token' => $device->api_key,
|
|
||||||
'battery-percent' => '67',
|
|
||||||
])->get('/api/display')->assertOk();
|
|
||||||
|
|
||||||
$device->refresh();
|
|
||||||
expect($device->battery_percent)->toEqual(67);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('display endpoint accepts Percent-Charged header and updates device', function (): void {
|
|
||||||
$device = Device::factory()->create([
|
|
||||||
'mac_address' => '00:11:22:33:44:57',
|
|
||||||
'api_key' => 'test-api-key-percent-charged',
|
|
||||||
'last_battery_voltage' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->withHeaders([
|
|
||||||
'id' => $device->mac_address,
|
|
||||||
'access-token' => $device->api_key,
|
|
||||||
'Percent-Charged' => '51',
|
|
||||||
])->get('/api/display')->assertOk();
|
|
||||||
|
|
||||||
$device->refresh();
|
|
||||||
expect($device->battery_percent)->toEqual(51);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void {
|
test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void {
|
||||||
// Create source device
|
// Create source device
|
||||||
$sourceDevice = Device::factory()->create([
|
$sourceDevice = Device::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,8 @@ test('firmware check command has correct signature', function (): void {
|
||||||
|
|
||||||
test('firmware check command runs without errors', function (): void {
|
test('firmware check command runs without errors', function (): void {
|
||||||
// Mock the firmware API response
|
// Mock the firmware API response
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -35,10 +33,8 @@ test('firmware check command runs without errors', function (): void {
|
||||||
|
|
||||||
test('firmware check command runs with download flag', function (): void {
|
test('firmware check command runs with download flag', function (): void {
|
||||||
// Mock the firmware API response
|
// Mock the firmware API response
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -61,10 +57,8 @@ test('firmware check command runs with download flag', function (): void {
|
||||||
|
|
||||||
test('firmware check command can run successfully', function (): void {
|
test('firmware check command can run successfully', function (): void {
|
||||||
// Mock the firmware API response
|
// Mock the firmware API response
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\DeviceModel;
|
use App\Models\DeviceModel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('allows a user to view the device models page', function (): void {
|
it('allows a user to view the device models page', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
@ -89,38 +87,3 @@ it('redirects unauthenticated users from the device models page', function (): v
|
||||||
|
|
||||||
$response->assertRedirect('/login');
|
$response->assertRedirect('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update from API runs job and refreshes device models', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'name' => 'api-model',
|
|
||||||
'label' => 'API Model',
|
|
||||||
'description' => 'From API',
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'colors' => 4,
|
|
||||||
'bit_depth' => 2,
|
|
||||||
'scale_factor' => 1.0,
|
|
||||||
'rotation' => 0,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
'offset_x' => 0,
|
|
||||||
'offset_y' => 0,
|
|
||||||
'kind' => 'trmnl',
|
|
||||||
'published_at' => '2023-01-01T00:00:00Z',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = Livewire::test('device-models.index')
|
|
||||||
->call('updateFromApi');
|
|
||||||
|
|
||||||
$deviceModels = $component->get('deviceModels');
|
|
||||||
expect($deviceModels->pluck('name')->toArray())->toContain('api-model');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ namespace Tests\Feature;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
use function Pest\Laravel\actingAs;
|
use function Pest\Laravel\actingAs;
|
||||||
|
|
||||||
|
|
@ -24,35 +23,3 @@ test('configure view displays last_refreshed_at timestamp', function (): void {
|
||||||
$response->assertOk()
|
$response->assertOk()
|
||||||
->assertSee('5 minutes ago');
|
->assertSee('5 minutes ago');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('configure edit modal shows mirror checkbox and allows unchecking mirror', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
actingAs($user);
|
|
||||||
|
|
||||||
$deviceAttributes = [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rotate' => 0,
|
|
||||||
'image_format' => 'png',
|
|
||||||
'maximum_compatibility' => false,
|
|
||||||
];
|
|
||||||
$sourceDevice = Device::factory()->create($deviceAttributes);
|
|
||||||
$mirrorDevice = Device::factory()->create([
|
|
||||||
...$deviceAttributes,
|
|
||||||
'mirror_device_id' => $sourceDevice->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get(route('devices.configure', $mirrorDevice));
|
|
||||||
$response->assertOk()
|
|
||||||
->assertSee('Mirrors Device')
|
|
||||||
->assertSee('Select Device to Mirror');
|
|
||||||
|
|
||||||
Livewire::test('devices.configure', ['device' => $mirrorDevice])
|
|
||||||
->set('is_mirror', false)
|
|
||||||
->call('updateDevice')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
$mirrorDevice->refresh();
|
|
||||||
expect($mirrorDevice->mirror_device_id)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
use App\Jobs\GenerateScreenJob;
|
use App\Jobs\GenerateScreenJob;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DeviceModel;
|
|
||||||
use App\Models\Plugin;
|
|
||||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
|
@ -60,26 +58,3 @@ test('it preserves gitignore file during cleanup', function (): void {
|
||||||
|
|
||||||
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it saves current_image_metadata for recipe plugins', function (): void {
|
|
||||||
$deviceModel = DeviceModel::factory()->create([
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rotation' => 0,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
'palette_id' => null,
|
|
||||||
]);
|
|
||||||
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
|
|
||||||
$plugin = Plugin::factory()->create(['plugin_type' => 'recipe']);
|
|
||||||
|
|
||||||
$job = new GenerateScreenJob($device->id, $plugin->id, '<div>Test</div>');
|
|
||||||
$job->handle();
|
|
||||||
|
|
||||||
$plugin->refresh();
|
|
||||||
expect($plugin->current_image)->not->toBeNull();
|
|
||||||
expect($plugin->current_image_metadata)->toBeArray();
|
|
||||||
expect($plugin->current_image_metadata)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
|
|
||||||
expect($plugin->current_image_metadata['width'])->toBe(800);
|
|
||||||
expect($plugin->current_image_metadata['height'])->toBe(480);
|
|
||||||
expect($plugin->current_image_metadata['mime_type'])->toBe('image/png');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ use App\Models\DeviceModel;
|
||||||
use App\Services\ImageGenerationService;
|
use App\Services\ImageGenerationService;
|
||||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -23,10 +22,6 @@ afterEach(function (): void {
|
||||||
TrmnlPipeline::restore();
|
TrmnlPipeline::restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plugins table has current_image_metadata column', function (): void {
|
|
||||||
expect(Schema::hasColumn('plugins', 'current_image_metadata'))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates image for device without device model', function (): void {
|
it('generates image for device without device model', function (): void {
|
||||||
// Create a device without a DeviceModel (legacy behavior)
|
// Create a device without a DeviceModel (legacy behavior)
|
||||||
$device = Device::factory()->create([
|
$device = Device::factory()->create([
|
||||||
|
|
@ -275,15 +270,39 @@ it('cleanupFolder preserves .gitignore', function (): void {
|
||||||
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resetIfNotCacheable does not reset recipe cache based on other devices', function (): void {
|
it('resetIfNotCacheable resets when device models exist', function (): void {
|
||||||
// Cache validity is now determined at use-time via current_image_metadata
|
// Create a plugin
|
||||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']);
|
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||||
|
|
||||||
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
|
// Create a device with DeviceModel (should trigger cache reset)
|
||||||
|
Device::factory()->create([
|
||||||
|
'device_model_id' => DeviceModel::factory()->create()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run reset check
|
||||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||||
|
|
||||||
|
// Assert plugin image was reset
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
expect($plugin->current_image)->toBe('test-uuid');
|
expect($plugin->current_image)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetIfNotCacheable resets when custom dimensions exist', function (): void {
|
||||||
|
// Create a plugin
|
||||||
|
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||||
|
|
||||||
|
// Create a device with custom dimensions (should trigger cache reset)
|
||||||
|
Device::factory()->create([
|
||||||
|
'width' => 1024, // Different from default 800
|
||||||
|
'height' => 768, // Different from default 480
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run reset check
|
||||||
|
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||||
|
|
||||||
|
// Assert plugin image was reset
|
||||||
|
$plugin->refresh();
|
||||||
|
expect($plugin->current_image)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resetIfNotCacheable preserves image for standard devices', function (): void {
|
it('resetIfNotCacheable preserves image for standard devices', function (): void {
|
||||||
|
|
@ -306,122 +325,27 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cache is reset when plugin markup changes', function (): void {
|
it('cache is reset when plugin markup changes', function (): void {
|
||||||
// Create a plugin with cached image and metadata
|
// Create a plugin with cached image
|
||||||
$plugin = App\Models\Plugin::factory()->create([
|
$plugin = App\Models\Plugin::factory()->create([
|
||||||
'current_image' => 'cached-uuid',
|
'current_image' => 'cached-uuid',
|
||||||
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
|
|
||||||
'render_markup' => '<div>Original markup</div>',
|
'render_markup' => '<div>Original markup</div>',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$plugin->update(['render_markup' => '<div>Updated markup</div>']);
|
// Create devices with standard dimensions (cacheable)
|
||||||
|
Device::factory()->count(2)->create([
|
||||||
|
'width' => 800,
|
||||||
|
'height' => 480,
|
||||||
|
'rotate' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update the plugin markup
|
||||||
|
$plugin->update([
|
||||||
|
'render_markup' => '<div>Updated markup</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert cache was reset when markup changed
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
expect($plugin->current_image)->toBeNull();
|
expect($plugin->current_image)->toBeNull();
|
||||||
expect($plugin->current_image_metadata)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('buildImageMetadataFromDevice returns canonical metadata shape', function (): void {
|
|
||||||
$deviceModel = DeviceModel::factory()->create([
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rotation' => 0,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
'palette_id' => null,
|
|
||||||
]);
|
|
||||||
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
|
|
||||||
|
|
||||||
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
|
|
||||||
|
|
||||||
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
|
|
||||||
expect($meta['width'])->toBe(800);
|
|
||||||
expect($meta['height'])->toBe(480);
|
|
||||||
expect($meta['rotation'])->toBe(0);
|
|
||||||
expect($meta['mime_type'])->toBe('image/png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('buildImageMetadataFromDeviceModel returns canonical metadata shape', function (): void {
|
|
||||||
$model = DeviceModel::factory()->create([
|
|
||||||
'width' => 1024,
|
|
||||||
'height' => 768,
|
|
||||||
'rotation' => 90,
|
|
||||||
'mime_type' => 'image/bmp',
|
|
||||||
'palette_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$meta = ImageGenerationService::buildImageMetadataFromDeviceModel($model);
|
|
||||||
|
|
||||||
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
|
|
||||||
expect($meta['width'])->toBe(1024);
|
|
||||||
expect($meta['height'])->toBe(768);
|
|
||||||
expect($meta['rotation'])->toBe(90);
|
|
||||||
expect($meta['mime_type'])->toBe('image/bmp');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imageMetadataMatches returns false when stored is null or empty', function (): void {
|
|
||||||
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
|
|
||||||
|
|
||||||
expect(ImageGenerationService::imageMetadataMatches(null, $device))->toBeFalse();
|
|
||||||
expect(ImageGenerationService::imageMetadataMatches([], $device))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imageMetadataMatches returns true when metadata matches device', function (): void {
|
|
||||||
$deviceModel = DeviceModel::factory()->create([
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rotation' => 0,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
'palette_id' => null,
|
|
||||||
]);
|
|
||||||
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
|
|
||||||
$stored = ImageGenerationService::buildImageMetadataFromDevice($device);
|
|
||||||
|
|
||||||
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imageMetadataMatches returns false when metadata differs', function (): void {
|
|
||||||
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
|
|
||||||
$stored = ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'];
|
|
||||||
|
|
||||||
$device->update(['width' => 1024]);
|
|
||||||
$device->refresh();
|
|
||||||
|
|
||||||
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resetIfNotCacheable clears recipe cache when metadata does not match', function (): void {
|
|
||||||
$plugin = App\Models\Plugin::factory()->create([
|
|
||||||
'plugin_type' => 'recipe',
|
|
||||||
'current_image' => 'cached-uuid',
|
|
||||||
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
|
|
||||||
]);
|
|
||||||
$device = Device::factory()->create(['width' => 1024, 'height' => 768, 'rotate' => 0]);
|
|
||||||
|
|
||||||
ImageGenerationService::resetIfNotCacheable($plugin, $device);
|
|
||||||
|
|
||||||
$plugin->refresh();
|
|
||||||
expect($plugin->current_image)->toBeNull();
|
|
||||||
expect($plugin->current_image_metadata)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resetIfNotCacheable preserves cache when metadata matches', function (): void {
|
|
||||||
$deviceModel = DeviceModel::factory()->create([
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rotation' => 0,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
]);
|
|
||||||
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
|
|
||||||
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
|
|
||||||
$plugin = App\Models\Plugin::factory()->create([
|
|
||||||
'plugin_type' => 'recipe',
|
|
||||||
'current_image' => 'cached-uuid',
|
|
||||||
'current_image_metadata' => $meta,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ImageGenerationService::resetIfNotCacheable($plugin, $device);
|
|
||||||
|
|
||||||
$plugin->refresh();
|
|
||||||
expect($plugin->current_image)->toBe('cached-uuid');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('determines correct image format from device model', function (): void {
|
it('determines correct image format from device model', function (): void {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ test('fetch device models job can be dispatched', function (): void {
|
||||||
test('fetch device models job handles successful api response', function (): void {
|
test('fetch device models job handles successful api response', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'name' => 'test-model',
|
'name' => 'test-model',
|
||||||
|
|
@ -82,7 +82,7 @@ test('fetch device models job handles successful api response', function (): voi
|
||||||
test('fetch device models job handles multiple device models', function (): void {
|
test('fetch device models job handles multiple device models', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'name' => 'model-1',
|
'name' => 'model-1',
|
||||||
|
|
@ -136,7 +136,7 @@ test('fetch device models job handles multiple device models', function (): void
|
||||||
test('fetch device models job handles empty data array', function (): void {
|
test('fetch device models job handles empty data array', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [],
|
'data' => [],
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
@ -158,7 +158,7 @@ test('fetch device models job handles empty data array', function (): void {
|
||||||
test('fetch device models job handles missing data field', function (): void {
|
test('fetch device models job handles missing data field', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'message' => 'No data available',
|
'message' => 'No data available',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
@ -180,7 +180,7 @@ test('fetch device models job handles missing data field', function (): void {
|
||||||
test('fetch device models job handles non-array data', function (): void {
|
test('fetch device models job handles non-array data', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => 'invalid-data',
|
'data' => 'invalid-data',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
@ -202,7 +202,7 @@ test('fetch device models job handles non-array data', function (): void {
|
||||||
test('fetch device models job handles api failure', function (): void {
|
test('fetch device models job handles api failure', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'error' => 'Internal Server Error',
|
'error' => 'Internal Server Error',
|
||||||
], 500),
|
], 500),
|
||||||
]);
|
]);
|
||||||
|
|
@ -227,7 +227,7 @@ test('fetch device models job handles api failure', function (): void {
|
||||||
test('fetch device models job handles network exception', function (): void {
|
test('fetch device models job handles network exception', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => function (): void {
|
'usetrmnl.com/api/models' => function (): void {
|
||||||
throw new Exception('Network connection failed');
|
throw new Exception('Network connection failed');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -249,7 +249,7 @@ test('fetch device models job handles network exception', function (): void {
|
||||||
test('fetch device models job handles device model with missing name', function (): void {
|
test('fetch device models job handles device model with missing name', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'label' => 'Model without name',
|
'label' => 'Model without name',
|
||||||
|
|
@ -280,7 +280,7 @@ test('fetch device models job handles device model with missing name', function
|
||||||
test('fetch device models job handles device model with partial data', function (): void {
|
test('fetch device models job handles device model with partial data', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'name' => 'minimal-model',
|
'name' => 'minimal-model',
|
||||||
|
|
@ -329,7 +329,7 @@ test('fetch device models job updates existing device model', function (): void
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'name' => 'existing-model',
|
'name' => 'existing-model',
|
||||||
|
|
@ -372,7 +372,7 @@ test('fetch device models job updates existing device model', function (): void
|
||||||
test('fetch device models job handles processing exception for individual model', function (): void {
|
test('fetch device models job handles processing exception for individual model', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'name' => 'valid-model',
|
'name' => 'valid-model',
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,8 @@ beforeEach(function (): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it creates new firmware record when polling', function (): void {
|
test('it creates new firmware record when polling', function (): void {
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -34,10 +32,8 @@ test('it updates existing firmware record when polling', function (): void {
|
||||||
'latest' => true,
|
'latest' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'url' => 'https://new-url.com/firmware.bin',
|
'url' => 'https://new-url.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -56,10 +52,8 @@ test('it marks previous firmware as not latest when new version is found', funct
|
||||||
'latest' => true,
|
'latest' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.1.0',
|
'version' => '1.1.0',
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
|
|
@ -72,10 +66,8 @@ test('it marks previous firmware as not latest when new version is found', funct
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it handles connection exception gracefully', function (): void {
|
test('it handles connection exception gracefully', function (): void {
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => function (): void {
|
'https://usetrmnl.com/api/firmware/latest' => function (): void {
|
||||||
throw new ConnectionException('Connection failed');
|
throw new ConnectionException('Connection failed');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -87,10 +79,8 @@ test('it handles connection exception gracefully', function (): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it handles invalid response gracefully', function (): void {
|
test('it handles invalid response gracefully', function (): void {
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response(null, 200),
|
'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(new FirmwarePollJob)->handle();
|
(new FirmwarePollJob)->handle();
|
||||||
|
|
@ -100,10 +90,8 @@ test('it handles invalid response gracefully', function (): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it handles missing version in response gracefully', function (): void {
|
test('it handles missing version in response gracefully', function (): void {
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'url' => 'https://example.com/firmware.bin',
|
'url' => 'https://example.com/firmware.bin',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
@ -115,10 +103,8 @@ test('it handles missing version in response gracefully', function (): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it handles missing url in response gracefully', function (): void {
|
test('it handles missing url in response gracefully', function (): void {
|
||||||
$baseUrl = config('services.trmnl.base_url');
|
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
$baseUrl.'/api/firmware/latest' => Http::response([
|
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use Livewire\Livewire;
|
||||||
|
|
||||||
it('loads newest TRMNL recipes on mount', function (): void {
|
it('loads newest TRMNL recipes on mount', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -33,7 +33,7 @@ it('loads newest TRMNL recipes on mount', function (): void {
|
||||||
|
|
||||||
it('shows preview button when screenshot_url is provided', function (): void {
|
it('shows preview button when screenshot_url is provided', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -57,7 +57,7 @@ it('shows preview button when screenshot_url is provided', function (): void {
|
||||||
it('searches TRMNL recipes when search term is provided', function (): void {
|
it('searches TRMNL recipes when search term is provided', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
// First call (mount -> newest)
|
// First call (mount -> newest)
|
||||||
config('services.trmnl.base_url').'/recipes.json?*' => Http::sequence()
|
'usetrmnl.com/recipes.json?*' => Http::sequence()
|
||||||
->push([
|
->push([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -98,7 +98,7 @@ it('installs plugin successfully when user is authenticated', function (): void
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -110,7 +110,7 @@ it('installs plugin successfully when user is authenticated', function (): void
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
], 200),
|
], 200),
|
||||||
config('services.trmnl.base_url').'/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
|
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
@ -125,7 +125,7 @@ it('installs plugin successfully when user is authenticated', function (): void
|
||||||
|
|
||||||
it('shows error when user is not authenticated', function (): void {
|
it('shows error when user is not authenticated', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -151,7 +151,7 @@ it('shows error when plugin installation fails', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -163,7 +163,7 @@ it('shows error when plugin installation fails', function (): void {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
], 200),
|
], 200),
|
||||||
config('services.trmnl.base_url').'/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
|
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
@ -178,7 +178,7 @@ it('shows error when plugin installation fails', function (): void {
|
||||||
|
|
||||||
it('previews a recipe with async fetch', function (): void {
|
it('previews a recipe with async fetch', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
|
|
@ -190,7 +190,7 @@ it('previews a recipe with async fetch', function (): void {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
], 200),
|
], 200),
|
||||||
config('services.trmnl.base_url').'/recipes/123.json' => Http::response([
|
'usetrmnl.com/recipes/123.json' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
'id' => 123,
|
'id' => 123,
|
||||||
'name' => 'Weather Chum Updated',
|
'name' => 'Weather Chum Updated',
|
||||||
|
|
@ -216,7 +216,7 @@ it('previews a recipe with async fetch', function (): void {
|
||||||
|
|
||||||
it('supports pagination and loading more recipes', function (): void {
|
it('supports pagination and loading more recipes', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=1' => Http::response([
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
|
|
@ -229,7 +229,7 @@ it('supports pagination and loading more recipes', function (): void {
|
||||||
],
|
],
|
||||||
'next_page_url' => '/recipes.json?page=2',
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
], 200),
|
], 200),
|
||||||
config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=2' => Http::response([
|
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
'id' => 2,
|
'id' => 2,
|
||||||
|
|
@ -258,7 +258,7 @@ it('supports pagination and loading more recipes', function (): void {
|
||||||
|
|
||||||
it('resets pagination when search term changes', function (): void {
|
it('resets pagination when search term changes', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
||||||
->push([
|
->push([
|
||||||
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
||||||
'next_page_url' => '/recipes.json?page=2',
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
|
|
@ -267,7 +267,7 @@ it('resets pagination when search term changes', function (): void {
|
||||||
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
||||||
'next_page_url' => null,
|
'next_page_url' => null,
|
||||||
]),
|
]),
|
||||||
config('services.trmnl.base_url').'/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
||||||
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
||||||
'next_page_url' => null,
|
'next_page_url' => null,
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\DevicePalette;
|
use App\Models\DevicePalette;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
|
@ -571,29 +570,3 @@ test('component refreshes palette list after deleting', function (): void {
|
||||||
expect($palettes)->toHaveCount($initialCount + 1);
|
expect($palettes)->toHaveCount($initialCount + 1);
|
||||||
expect(DevicePalette::count())->toBe($initialCount + 1);
|
expect(DevicePalette::count())->toBe($initialCount + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update from API runs job and refreshes device palettes', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/api/palettes' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'id' => 'api-palette',
|
|
||||||
'name' => 'API Palette',
|
|
||||||
'grays' => 4,
|
|
||||||
'colors' => null,
|
|
||||||
'framework_class' => '',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], 200),
|
|
||||||
config('services.trmnl.base_url').'/api/models' => Http::response(['data' => []], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = Livewire::test('device-palettes.index')
|
|
||||||
->call('updateFromApi');
|
|
||||||
|
|
||||||
$devicePalettes = $component->get('devicePalettes');
|
|
||||||
expect($devicePalettes->pluck('name')->toArray())->toContain('api-palette');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -109,124 +109,3 @@ test('recipe settings can clear trmnlp_id', function (): void {
|
||||||
|
|
||||||
expect($plugin->fresh()->trmnlp_id)->toBeNull();
|
expect($plugin->fresh()->trmnlp_id)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('recipe settings saves preferred_renderer when liquid enabled and recipe is liquid', function (): void {
|
|
||||||
config(['services.trmnl.liquid_enabled' => true]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'markup_language' => 'liquid',
|
|
||||||
'preferred_renderer' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('use_trmnl_liquid_renderer', true)
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect($plugin->fresh()->preferred_renderer)->toBe('trmnl-liquid');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipe settings clears preferred_renderer when checkbox unchecked', function (): void {
|
|
||||||
config(['services.trmnl.liquid_enabled' => true]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'markup_language' => 'liquid',
|
|
||||||
'preferred_renderer' => 'trmnl-liquid',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('use_trmnl_liquid_renderer', false)
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect($plugin->fresh()->preferred_renderer)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipe settings saves configuration_template from valid YAML', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'configuration_template' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$yaml = "- keyname: reading_days\n field_type: text\n name: Reading Days\n";
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('configurationTemplateYaml', $yaml)
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
$expected = [
|
|
||||||
'custom_fields' => [
|
|
||||||
[
|
|
||||||
'keyname' => 'reading_days',
|
|
||||||
'field_type' => 'text',
|
|
||||||
'name' => 'Reading Days',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
expect($plugin->fresh()->configuration_template)->toBe($expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipe settings validates invalid YAML', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'configuration_template' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('configurationTemplateYaml', "foo: bar: baz\n")
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasErrors(['configurationTemplateYaml']);
|
|
||||||
|
|
||||||
expect($plugin->fresh()->configuration_template)->toBe([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipe settings validates YAML must evaluate to object or array', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'configuration_template' => ['custom_fields' => []],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('configurationTemplateYaml', '123')
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasErrors(['configurationTemplateYaml']);
|
|
||||||
|
|
||||||
expect($plugin->fresh()->configuration_template)->toBe(['custom_fields' => []]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipe settings validates each custom field has field_type and name', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'configuration_template' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$yaml = "- keyname: only_key\n field_type: text\n name: Has Name\n- keyname: missing_type\n name: No type\n";
|
|
||||||
|
|
||||||
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
|
|
||||||
->set('configurationTemplateYaml', $yaml)
|
|
||||||
->call('saveTrmnlpId')
|
|
||||||
->assertHasErrors(['configurationTemplateYaml']);
|
|
||||||
|
|
||||||
expect($plugin->fresh()->configuration_template)->toBeEmpty();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
|
|
||||||
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
||||||
|
|
||||||
test('auth pages show pixel logo SVG when pixel_logo_enabled is true', function (): void {
|
|
||||||
Config::set('app.pixel_logo_enabled', true);
|
|
||||||
|
|
||||||
$response = $this->get('/login');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertSee('viewBox="0 0 1000 150"', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('auth pages show heading instead of pixel logo when pixel_logo_enabled is false', function (): void {
|
|
||||||
Config::set('app.pixel_logo_enabled', false);
|
|
||||||
|
|
||||||
$response = $this->get('/login');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertDontSee('viewBox="0 0 1000 150"', false);
|
|
||||||
$response->assertSee('LaraPaper');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('app logo shows text when pixel_logo_enabled is false', function (): void {
|
|
||||||
Config::set('app.pixel_logo_enabled', false);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$response = $this->actingAs($user)->get(route('dashboard'));
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertSee('LaraPaper');
|
|
||||||
$response->assertDontSee('viewBox="0 0 1000 150"', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('app logo shows pixel logo SVG when pixel_logo_enabled is true', function (): void {
|
|
||||||
Config::set('app.pixel_logo_enabled', true);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$response = $this->actingAs($user)->get(route('dashboard'));
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$response->assertSee('viewBox="0 0 1000 150"', false);
|
|
||||||
});
|
|
||||||
|
|
@ -52,10 +52,8 @@ it('imports plugin with shared.liquid file', function (): void {
|
||||||
$pluginImportService = new PluginImportService();
|
$pluginImportService = new PluginImportService();
|
||||||
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
expect($plugin->render_markup_shared)->toBe('{% comment %}Shared styles{% endcomment %}')
|
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}')
|
||||||
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
|
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">');
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Shared styles{% endcomment %}')
|
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports plugin with files in root directory', function (): void {
|
it('imports plugin with files in root directory', function (): void {
|
||||||
|
|
@ -204,10 +202,8 @@ it('imports plugin from monorepo with shared.liquid in subdirectory', function (
|
||||||
$pluginImportService = new PluginImportService();
|
$pluginImportService = new PluginImportService();
|
||||||
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
expect($plugin->render_markup_shared)->toBe('{% comment %}Monorepo shared styles{% endcomment %}')
|
expect($plugin->render_markup)->toContain('{% comment %}Monorepo shared styles{% endcomment %}')
|
||||||
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
|
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">');
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Monorepo shared styles{% endcomment %}')
|
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports plugin from URL with zip_entry_path parameter', function (): void {
|
it('imports plugin from URL with zip_entry_path parameter', function (): void {
|
||||||
|
|
@ -356,10 +352,8 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
|
||||||
expect($plugin)->toBeInstanceOf(Plugin::class)
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
->and($plugin->user_id)->toBe($user->id)
|
->and($plugin->user_id)->toBe($user->id)
|
||||||
->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin
|
->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin
|
||||||
->and($plugin->render_markup_shared)->toBe('{% comment %}Plugin 2 shared styles{% endcomment %}')
|
->and($plugin->render_markup)->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}')
|
||||||
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>')
|
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>');
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}')
|
|
||||||
->and($plugin->getMarkupForSize('full'))->toContain('<div class="plugin2-content">Plugin 2 content</div>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
|
it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
|
||||||
|
|
@ -522,8 +516,8 @@ it('imports plugin with only shared.liquid file', function (): void {
|
||||||
|
|
||||||
expect($plugin)->toBeInstanceOf(Plugin::class)
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
->and($plugin->markup_language)->toBe('liquid')
|
->and($plugin->markup_language)->toBe('liquid')
|
||||||
->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ data.title }}</div>')
|
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
|
||||||
->and($plugin->render_markup)->toBeNull();
|
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports plugin with only shared.blade.php file', function (): void {
|
it('imports plugin with only shared.blade.php file', function (): void {
|
||||||
|
|
@ -541,8 +535,8 @@ it('imports plugin with only shared.blade.php file', function (): void {
|
||||||
|
|
||||||
expect($plugin)->toBeInstanceOf(Plugin::class)
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
->and($plugin->markup_language)->toBe('blade')
|
->and($plugin->markup_language)->toBe('blade')
|
||||||
->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
|
->and($plugin->render_markup)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
|
||||||
->and($plugin->render_markup)->toBeNull();
|
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -239,34 +238,3 @@ LIQUID
|
||||||
$this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result);
|
$this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result);
|
||||||
$this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result);
|
$this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shared template receives trmnl context when', function (): void {
|
|
||||||
$user = User::factory()->create(['name' => 'Jane Smith']);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'name' => 'Departures',
|
|
||||||
'markup_language' => 'liquid',
|
|
||||||
'render_markup_shared' => <<<'LIQUID'
|
|
||||||
{% template departures_view %}
|
|
||||||
<div class="title_bar">
|
|
||||||
<span class="title">Departures</span>
|
|
||||||
<span class="instance">{{ trmnl.user.name }}</span>
|
|
||||||
</div>
|
|
||||||
{% endtemplate %}
|
|
||||||
LIQUID
|
|
||||||
,
|
|
||||||
'render_markup' => <<<'LIQUID'
|
|
||||||
<div class="view">
|
|
||||||
{% render "departures_view", station: "Hauptbahnhof" %}
|
|
||||||
</div>
|
|
||||||
LIQUID
|
|
||||||
,
|
|
||||||
'data_payload' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = $plugin->render('full');
|
|
||||||
|
|
||||||
$this->assertStringContainsString('Jane Smith', $result);
|
|
||||||
$this->assertStringContainsString('class="instance"', $result);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ test('hasMissingRequiredConfigurationFields returns true when required xhrSelect
|
||||||
'field_type' => 'xhrSelect',
|
'field_type' => 'xhrSelect',
|
||||||
'name' => 'Baseball Team',
|
'name' => 'Baseball Team',
|
||||||
'description' => 'Select your team',
|
'description' => 'Select your team',
|
||||||
'endpoint' => config('services.trmnl.base_url').'/custom_plugin_example_xhr_select.json',
|
'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json',
|
||||||
// Not marked as optional, so it's required
|
// Not marked as optional, so it's required
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -252,7 +252,7 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec
|
||||||
'field_type' => 'xhrSelect',
|
'field_type' => 'xhrSelect',
|
||||||
'name' => 'Baseball Team',
|
'name' => 'Baseball Team',
|
||||||
'description' => 'Select your team',
|
'description' => 'Select your team',
|
||||||
'endpoint' => config('services.trmnl.base_url').'/custom_plugin_example_xhr_select.json',
|
'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json',
|
||||||
// Not marked as optional, so it's required
|
// Not marked as optional, so it's required
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ test('plugin parses JSON responses correctly', function (): void {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin parses RSS XML responses and wraps under rss key', function (): void {
|
test('plugin parses XML responses and wraps under rss key', function (): void {
|
||||||
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>
|
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
|
|
@ -73,33 +73,6 @@ test('plugin parses RSS XML responses and wraps under rss key', function (): voi
|
||||||
expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2);
|
expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin parses namespaces XML responses and wraps under root key', function (): void {
|
|
||||||
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<foo:cake version="2.0" xmlns:foo="http://example.com/foo">
|
|
||||||
<bar:icing xmlns:bar="http://example.com/bar">
|
|
||||||
<ontop>Cherry</ontop>
|
|
||||||
</bar:icing>
|
|
||||||
</foo:cake>';
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'example.com/namespace.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'data_strategy' => 'polling',
|
|
||||||
'polling_url' => 'https://example.com/namespace.xml',
|
|
||||||
'polling_verb' => 'get',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin->updateDataPayload();
|
|
||||||
|
|
||||||
$plugin->refresh();
|
|
||||||
|
|
||||||
expect($plugin->data_payload)->toHaveKey('cake');
|
|
||||||
expect($plugin->data_payload['cake'])->toHaveKey('icing');
|
|
||||||
expect($plugin->data_payload['cake']['icing']['ontop'])->toBe('Cherry');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plugin parses JSON-parsable response body as JSON', function (): void {
|
test('plugin parses JSON-parsable response body as JSON', function (): void {
|
||||||
$jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}';
|
$jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}';
|
||||||
|
|
||||||
|
|
@ -191,8 +164,8 @@ test('plugin handles multiple URLs with mixed content types', function (): void
|
||||||
expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse);
|
expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse);
|
||||||
|
|
||||||
// Second URL should be XML wrapped under rss
|
// Second URL should be XML wrapped under rss
|
||||||
expect($plugin->data_payload['IDX_1'])->toHaveKey('root');
|
expect($plugin->data_payload['IDX_1'])->toHaveKey('rss');
|
||||||
expect($plugin->data_payload['IDX_1']['root']['item'])->toBe('XML Data');
|
expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin handles POST requests with XML responses', function (): void {
|
test('plugin handles POST requests with XML responses', function (): void {
|
||||||
|
|
@ -213,11 +186,11 @@ test('plugin handles POST requests with XML responses', function (): void {
|
||||||
|
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
|
|
||||||
expect($plugin->data_payload)->toHaveKey('response');
|
expect($plugin->data_payload)->toHaveKey('rss');
|
||||||
expect($plugin->data_payload['response'])->toHaveKey('status');
|
expect($plugin->data_payload['rss'])->toHaveKey('status');
|
||||||
expect($plugin->data_payload['response'])->toHaveKey('data');
|
expect($plugin->data_payload['rss'])->toHaveKey('data');
|
||||||
expect($plugin->data_payload['response']['status'])->toBe('success');
|
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
||||||
expect($plugin->data_payload['response']['data'])->toBe('test');
|
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin parses iCal responses and filters to recent window', function (): void {
|
test('plugin parses iCal responses and filters to recent window', function (): void {
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Plugin;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
test('iCal plugin parses Google Calendar invitation event', function (): void {
|
|
||||||
// Set test time close to the event in the issue
|
|
||||||
Carbon::setTestNow(Carbon::parse('2026-03-10 12:00:00', 'Europe/Budapest'));
|
|
||||||
|
|
||||||
$icalContent = <<<'ICS'
|
|
||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Example Corp.//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;TZID=Europe/Budapest:20260311T100000
|
|
||||||
DTEND;TZID=Europe/Budapest:20260311T110000
|
|
||||||
DTSTAMP:20260301T100000Z
|
|
||||||
ORGANIZER:mailto:organizer@example.com
|
|
||||||
UID:xxxxxxxxxxxxxxxxxxx@google.com
|
|
||||||
SEQUENCE:0
|
|
||||||
DESCRIPTION:-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
|
|
||||||
·:~:~:~:~:~:~:~:~::~:~::-
|
|
||||||
Csatlakozás a Google Meet szolgáltatással: https://meet.google.com/xxx-xxxx-xxx
|
|
||||||
|
|
||||||
További információ a Meetről: https://support.google.com/a/users/answer/9282720
|
|
||||||
|
|
||||||
Kérjük, ne szerkeszd ezt a szakaszt.
|
|
||||||
-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
|
|
||||||
·:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
|
|
||||||
LOCATION:Meet XY Street, ZIP; https://meet.google.com/xxx-xxxx-xxx
|
|
||||||
SUMMARY:Meeting
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant1@example.com
|
|
||||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant2@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant2@example.com
|
|
||||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant3@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=NEEDS-ACTION:mailto:participant3@example.com
|
|
||||||
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();
|
|
||||||
|
|
||||||
expect($plugin->data_payload)->not->toHaveKey('error');
|
|
||||||
expect($plugin->data_payload)->toHaveKey('ical');
|
|
||||||
expect($plugin->data_payload['ical'])->toHaveCount(1);
|
|
||||||
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Meeting');
|
|
||||||
|
|
||||||
Carbon::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('iCal plugin parses recurring events with multiple BYDAY correctly', function (): void {
|
|
||||||
// Set test now to Monday 2024-03-25
|
|
||||||
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
|
|
||||||
|
|
||||||
$icalContent = <<<'ICS'
|
|
||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//Example Corp.//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DESCRIPTION:XXX-REDACTED
|
|
||||||
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
|
|
||||||
UID:040000008200E00074C5B7101A82E00800000000E07AF34F937EDA01000000000000000
|
|
||||||
01000000061F3E918C753424E8154B36E55452933
|
|
||||||
SUMMARY:Recurring Meeting
|
|
||||||
DTSTART;VALUE=DATE:20240326
|
|
||||||
DTEND;VALUE=DATE:20240327
|
|
||||||
DTSTAMP:20240605T082436Z
|
|
||||||
CLASS:PUBLIC
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
ICS;
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'example.com/recurring.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'data_strategy' => 'polling',
|
|
||||||
'polling_url' => 'https://example.com/recurring.ics',
|
|
||||||
'polling_verb' => 'get',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin->updateDataPayload();
|
|
||||||
$plugin->refresh();
|
|
||||||
|
|
||||||
$ical = $plugin->data_payload['ical'];
|
|
||||||
|
|
||||||
// Week of March 25, 2024:
|
|
||||||
// Tue March 26: 2024-03-26 (DTSTART)
|
|
||||||
// Thu March 28: 2024-03-28 (Recurrence)
|
|
||||||
|
|
||||||
// The parser window is now-7 days to now+30 days.
|
|
||||||
// Window: 2024-03-18 to 2024-04-24.
|
|
||||||
|
|
||||||
$summaries = collect($ical)->pluck('SUMMARY');
|
|
||||||
expect($summaries)->toContain('Recurring Meeting');
|
|
||||||
|
|
||||||
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
|
|
||||||
|
|
||||||
// Check if Tuesday March 26 is present
|
|
||||||
expect($dates)->toContain('2024-03-26');
|
|
||||||
|
|
||||||
// Check if Thursday March 28 is present (THIS IS WHERE IT IS EXPECTED TO FAIL BASED ON THE ISSUE)
|
|
||||||
expect($dates)->toContain('2024-03-28');
|
|
||||||
|
|
||||||
Carbon::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('iCal plugin parses recurring events with multiple BYDAY and specific DTSTART correctly', function (): void {
|
|
||||||
// Set test now to Monday 2024-03-25
|
|
||||||
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
|
|
||||||
|
|
||||||
$icalContent = <<<'ICS'
|
|
||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
X-WR-TIMEZONE:UTC
|
|
||||||
PRODID:-//Example Corp.//EN
|
|
||||||
BEGIN:VEVENT
|
|
||||||
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
|
|
||||||
UID:recurring-event-2
|
|
||||||
SUMMARY:Recurring Meeting 2
|
|
||||||
DTSTART:20240326T100000
|
|
||||||
DTEND:20240326T110000
|
|
||||||
DTSTAMP:20240605T082436Z
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
ICS;
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'example.com/recurring2.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin = Plugin::factory()->create([
|
|
||||||
'data_strategy' => 'polling',
|
|
||||||
'polling_url' => 'https://example.com/recurring2.ics',
|
|
||||||
'polling_verb' => 'get',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$plugin->updateDataPayload();
|
|
||||||
$plugin->refresh();
|
|
||||||
|
|
||||||
$ical = $plugin->data_payload['ical'];
|
|
||||||
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
|
|
||||||
|
|
||||||
expect($dates)->toContain('2024-03-26');
|
|
||||||
expect($dates)->toContain('2024-03-28');
|
|
||||||
|
|
||||||
Carbon::setTestNow();
|
|
||||||
});
|
|
||||||