mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
feat: add trmnlp support
This commit is contained in:
parent
f1d5c196e8
commit
3ae0d22312
8 changed files with 718 additions and 61 deletions
186
app/Services/PluginExportService.php
Normal file
186
app/Services/PluginExportService.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* PluginExportService
|
||||
*
|
||||
* Exports plugins to ZIP files in the same format that can be imported by PluginImportService.
|
||||
*
|
||||
* The exported ZIP file contains:
|
||||
* - settings.yml: Plugin configuration including custom fields, polling settings, etc.
|
||||
* - full.liquid or full.blade.php: The main template file
|
||||
* - shared.liquid: Optional shared template (for liquid templates)
|
||||
*
|
||||
* This format is compatible with the PluginImportService and can be used to:
|
||||
* - Backup plugins
|
||||
* - Share plugins between users
|
||||
* - Migrate plugins between environments
|
||||
* - Create plugin templates
|
||||
*/
|
||||
class PluginExportService
|
||||
{
|
||||
/**
|
||||
* Export a plugin to a ZIP file in the same format that can be imported
|
||||
*
|
||||
* @param Plugin $plugin The plugin to export
|
||||
* @param User $user The user exporting the plugin
|
||||
* @return BinaryFileResponse The ZIP file response
|
||||
*
|
||||
* @throws Exception If the ZIP file cannot be created
|
||||
*/
|
||||
public function exportToZip(Plugin $plugin, User $user): BinaryFileResponse
|
||||
{
|
||||
// Create a temporary directory
|
||||
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
|
||||
try {
|
||||
// Generate settings.yml content
|
||||
$settings = $this->generateSettingsYaml($plugin);
|
||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||
|
||||
// Generate full template content
|
||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||
|
||||
// Generate shared.liquid if needed (for liquid templates)
|
||||
if ($plugin->markup_language === 'liquid') {
|
||||
$sharedTemplate = $this->generateSharedTemplate($plugin);
|
||||
if ($sharedTemplate) {
|
||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Could not create ZIP file.');
|
||||
}
|
||||
|
||||
// Add files directly to ZIP root
|
||||
$this->addDirectoryToZip($zip, $tempDir, '');
|
||||
$zip->close();
|
||||
|
||||
// Return the ZIP file as a download response
|
||||
return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the settings.yml content for the plugin
|
||||
*/
|
||||
private function generateSettingsYaml(Plugin $plugin): array
|
||||
{
|
||||
$settings = [];
|
||||
|
||||
// Add fields in the specific order requested
|
||||
$settings['name'] = $plugin->name;
|
||||
$settings['no_screen_padding'] = 'no'; // Default value
|
||||
$settings['dark_mode'] = 'no'; // Default value
|
||||
$settings['strategy'] = $plugin->data_strategy;
|
||||
|
||||
// Add static data if available
|
||||
if ($plugin->data_payload) {
|
||||
$settings['static_data'] = json_encode($plugin->data_payload, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
// Add polling configuration if applicable
|
||||
if ($plugin->data_strategy === 'polling') {
|
||||
if ($plugin->polling_verb) {
|
||||
$settings['polling_verb'] = $plugin->polling_verb;
|
||||
}
|
||||
if ($plugin->polling_url) {
|
||||
$settings['polling_url'] = $plugin->polling_url;
|
||||
}
|
||||
if ($plugin->polling_header) {
|
||||
// Convert header format from "key: value" to "key=value"
|
||||
$settings['polling_headers'] = str_replace(':', '=', $plugin->polling_header);
|
||||
}
|
||||
if ($plugin->polling_body) {
|
||||
$settings['polling_body'] = $plugin->polling_body;
|
||||
}
|
||||
}
|
||||
|
||||
$settings['refresh_interval'] = $plugin->data_stale_minutes;
|
||||
$settings['id'] = $plugin->trmnlp_id;
|
||||
|
||||
// Add custom fields from configuration template
|
||||
if (isset($plugin->configuration_template['custom_fields'])) {
|
||||
$settings['custom_fields'] = $plugin->configuration_template['custom_fields'];
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full template content
|
||||
*/
|
||||
private function generateFullTemplate(Plugin $plugin): string
|
||||
{
|
||||
$markup = $plugin->render_markup;
|
||||
|
||||
// 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('/\s*<\/div>\s*$/', '', $markup);
|
||||
|
||||
return trim($markup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the shared template content (for liquid templates)
|
||||
*/
|
||||
private function generateSharedTemplate(Plugin $plugin)
|
||||
{
|
||||
// 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
|
||||
*/
|
||||
private function addDirectoryToZip(ZipArchive $zip, string $dirPath, string $zipPath): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dirPath),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$fileName = basename($filePath);
|
||||
|
||||
// For root directory, just use the filename
|
||||
if ($zipPath === '') {
|
||||
$relativePath = $fileName;
|
||||
} else {
|
||||
$relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1);
|
||||
}
|
||||
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use Illuminate\Support\Facades\File;
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
|
|
@ -82,34 +83,45 @@ class PluginImportService
|
|||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
if (isset($settings['custom_fields']) && is_array($settings['custom_fields'])) {
|
||||
$plugin_updated = isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['keyname']) && isset($field['default'])) {
|
||||
$configuration[$field['keyname']] = $field['default'];
|
||||
}
|
||||
}
|
||||
// set only if plugin is new
|
||||
$plugin->update([
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'configuration' => $configuration,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
]);
|
||||
$plugin['trmnlp_yaml'] = $settingsYaml;
|
||||
|
||||
return $plugin;
|
||||
|
||||
|
|
@ -119,12 +131,6 @@ class PluginImportService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find required files in the extracted ZIP directory
|
||||
*
|
||||
* @param string $tempDir The temporary directory path
|
||||
* @return array Array containing paths to required files
|
||||
*/
|
||||
private function findRequiredFiles(string $tempDir): array
|
||||
{
|
||||
$settingsYamlPath = null;
|
||||
|
|
|
|||
66
composer.lock
generated
66
composer.lock
generated
|
|
@ -62,16 +62,16 @@
|
|||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.356.2",
|
||||
"version": "3.356.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "d4c61078d5f371c9a8a844946e96e12d6fa18080"
|
||||
"reference": "9c61b26408664c76d51101381bb64feda9bcfe2e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d4c61078d5f371c9a8a844946e96e12d6fa18080",
|
||||
"reference": "d4c61078d5f371c9a8a844946e96e12d6fa18080",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9c61b26408664c76d51101381bb64feda9bcfe2e",
|
||||
"reference": "9c61b26408664c76d51101381bb64feda9bcfe2e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -153,9 +153,9 @@
|
|||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.356.2"
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.356.3"
|
||||
},
|
||||
"time": "2025-08-21T18:09:26+00:00"
|
||||
"time": "2025-08-22T18:13:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bnussbau/laravel-trmnl-blade",
|
||||
|
|
@ -942,22 +942,22 @@
|
|||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.9.3",
|
||||
"version": "7.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
|
||||
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/promises": "^1.5.3 || ^2.0.3",
|
||||
"guzzlehttp/psr7": "^2.7.0",
|
||||
"guzzlehttp/promises": "^2.3",
|
||||
"guzzlehttp/psr7": "^2.8",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.2 || ^3.0"
|
||||
|
|
@ -1048,7 +1048,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.9.3"
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1064,7 +1064,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-03-27T13:37:11+00:00"
|
||||
"time": "2025-08-23T22:36:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
|
|
@ -1151,16 +1151,16 @@
|
|||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.7.1",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
|
||||
"reference": "21dc724a0583619cd1652f673303492272778051"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
|
||||
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
|
||||
"reference": "21dc724a0583619cd1652f673303492272778051",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -1176,7 +1176,7 @@
|
|||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"http-interop/http-factory-tests": "0.9.0",
|
||||
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
|
|
@ -1247,7 +1247,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.7.1"
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.8.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1263,7 +1263,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-03-27T12:30:47+00:00"
|
||||
"time": "2025-08-23T21:21:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/uri-template",
|
||||
|
|
@ -2857,16 +2857,16 @@
|
|||
},
|
||||
{
|
||||
"name": "livewire/flux",
|
||||
"version": "v2.2.5",
|
||||
"version": "v2.2.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/livewire/flux.git",
|
||||
"reference": "2295f14766f86006d889f37ee208bcf347a0d013"
|
||||
"reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/livewire/flux/zipball/2295f14766f86006d889f37ee208bcf347a0d013",
|
||||
"reference": "2295f14766f86006d889f37ee208bcf347a0d013",
|
||||
"url": "https://api.github.com/repos/livewire/flux/zipball/af81b5fd34c6490d5b5e05ed0f8140c0250e5069",
|
||||
"reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -2914,9 +2914,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/livewire/flux/issues",
|
||||
"source": "https://github.com/livewire/flux/tree/v2.2.5"
|
||||
"source": "https://github.com/livewire/flux/tree/v2.2.4"
|
||||
},
|
||||
"time": "2025-08-19T22:41:54+00:00"
|
||||
"time": "2025-08-09T01:46:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "livewire/livewire",
|
||||
|
|
@ -8371,16 +8371,16 @@
|
|||
},
|
||||
{
|
||||
"name": "larastan/larastan",
|
||||
"version": "v3.6.0",
|
||||
"version": "v3.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/larastan/larastan.git",
|
||||
"reference": "6431d010dd383a9279eb8874a76ddb571738564a"
|
||||
"reference": "3c223047e374befd1b64959784685d6ecccf66aa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a",
|
||||
"reference": "6431d010dd383a9279eb8874a76ddb571738564a",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/3c223047e374befd1b64959784685d6ecccf66aa",
|
||||
"reference": "3c223047e374befd1b64959784685d6ecccf66aa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -8448,7 +8448,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/larastan/larastan/issues",
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.6.0"
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.6.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -8456,7 +8456,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-11T06:52:52+00:00"
|
||||
"time": "2025-08-25T07:24:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->string('trmnlp_id')->nullable()->after('uuid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('trmnlp_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ use App\Models\DeviceModel;
|
|||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use App\Services\ImageGenerationService;
|
||||
use App\Services\PluginImportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -511,3 +512,48 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
|
|||
|
||||
return response()->json(['message' => 'Data updated successfully']);
|
||||
})->name('api.custom_plugins.webhook');
|
||||
|
||||
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
||||
if (! $trmnlp_id || trim($trmnlp_id) === '') {
|
||||
return response()->json([
|
||||
'message' => 'trmnlp_id is required',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Find the plugin by trmnlp_id and ensure it belongs to the authenticated user
|
||||
$plugin = Plugin::where('trmnlp_id', $trmnlp_id)
|
||||
->where('user_id', auth()->user()->id)
|
||||
->firstOrFail();
|
||||
|
||||
// Use the export service to create the ZIP file
|
||||
/** @var App\Services\PluginExportService $exporter */
|
||||
$exporter = app(App\Services\PluginExportService::class);
|
||||
|
||||
return $exporter->exportToZip($plugin, auth()->user());
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
||||
if (! $trmnlp_id) {
|
||||
return response()->json([
|
||||
'message' => 'trmnl_id is required',
|
||||
]);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:zip',
|
||||
]);
|
||||
|
||||
/** @var Illuminate\Http\UploadedFile $file */
|
||||
$file = $request->file('file');
|
||||
// Apply archive to existing plugin using the import service
|
||||
/** @var PluginImportService $importer */
|
||||
$importer = app(PluginImportService::class);
|
||||
$plugin = $importer->importFromZip($file, auth()->user());
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Plugin settings archive processed successfully',
|
||||
'data' => [
|
||||
'settings_yaml' => $plugin['trmnlp_yaml'],
|
||||
],
|
||||
]);
|
||||
})->middleware('auth:sanctum');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
|
|
@ -29,6 +31,14 @@ Route::middleware(['auth'])->group(function () {
|
|||
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
||||
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
|
||||
Volt::route('playlists', 'playlists.index')->name('playlists.index');
|
||||
|
||||
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
||||
$plugin = Plugin::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('trmnlp_id', $trmnlp_id)->firstOrFail();
|
||||
|
||||
return redirect()->route('plugins.recipe', ['plugin' => $plugin]);
|
||||
});
|
||||
});
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
|
|
|
|||
73
tests/Feature/Api/PluginSettingsArchiveTest.php
Normal file
73
tests/Feature/Api/PluginSettingsArchiveTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
it('accepts a plugin settings archive and updates the plugin', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'uuid' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Authenticate via Sanctum (endpoint requires auth:sanctum)
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
// Build a temporary ZIP with required structure: src/settings.yml and src/full.liquid
|
||||
$tempDir = sys_get_temp_dir().'/trmnl_zip_'.uniqid();
|
||||
$srcDir = $tempDir.'/src';
|
||||
if (! is_dir($srcDir)) {
|
||||
mkdir($srcDir, 0777, true);
|
||||
}
|
||||
|
||||
$settingsYaml = <<<'YAML'
|
||||
name: Sample Imported
|
||||
strategy: static
|
||||
refresh_interval: 10
|
||||
custom_fields:
|
||||
- keyname: title
|
||||
default: "Hello"
|
||||
static_data: '{"message":"world"}'
|
||||
YAML;
|
||||
|
||||
$fullLiquid = <<<'LIQUID'
|
||||
<h1>{{ config.title }}</h1>
|
||||
<div>{{ data.message }}</div>
|
||||
LIQUID;
|
||||
|
||||
file_put_contents($srcDir.'/settings.yml', $settingsYaml);
|
||||
file_put_contents($srcDir.'/full.liquid', $fullLiquid);
|
||||
|
||||
$zipPath = sys_get_temp_dir().'/plugin_'.uniqid().'.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
$zip->addFile($srcDir.'/settings.yml', 'src/settings.yml');
|
||||
$zip->addFile($srcDir.'/full.liquid', 'src/full.liquid');
|
||||
$zip->close();
|
||||
|
||||
// Prepare UploadedFile
|
||||
$uploaded = new UploadedFile($zipPath, 'plugin.zip', 'application/zip', null, true);
|
||||
|
||||
// Make request (multipart form-data)
|
||||
$response = $this->post('/api/plugin_settings/'.$plugin->uuid.'/archive', [
|
||||
'file' => $uploaded,
|
||||
], ['Accept' => 'application/json']);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$imported = Plugin::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('name', 'Sample Imported')
|
||||
->first();
|
||||
|
||||
expect($imported)->not->toBeNull();
|
||||
expect($imported->markup_language)->toBe('liquid');
|
||||
expect($imported->render_markup)->toContain('<h1>{{ config.title }}</h1>');
|
||||
// Configuration should have default for title (set on create)
|
||||
expect($imported->configuration['title'] ?? null)->toBe('Hello');
|
||||
});
|
||||
308
tests/Feature/PluginArchiveTest.php
Normal file
308
tests/Feature/PluginArchiveTest.php
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use App\Services\PluginExportService;
|
||||
use App\Services\PluginImportService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
it('exports plugin to zip file in correct format', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Test Plugin',
|
||||
'trmnlp_id' => 'test-plugin-123',
|
||||
'data_stale_minutes' => 30,
|
||||
'data_strategy' => 'static',
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div>Hello {{ config.name }}</div>',
|
||||
'configuration_template' => [
|
||||
'custom_fields' => [
|
||||
[
|
||||
'keyname' => 'name',
|
||||
'field_type' => 'text',
|
||||
'default' => 'World',
|
||||
],
|
||||
],
|
||||
],
|
||||
'data_payload' => ['message' => 'Hello World'],
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class);
|
||||
expect($response->getFile()->getFilename())->toContain('test-plugin-123.zip');
|
||||
});
|
||||
|
||||
it('exports plugin with polling configuration', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Polling Plugin',
|
||||
'trmnlp_id' => 'polling-plugin-456',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => 'https://api.example.com/data',
|
||||
'polling_verb' => 'post',
|
||||
'polling_header' => 'Authorization: Bearer token',
|
||||
'polling_body' => '{"key": "value"}',
|
||||
'markup_language' => 'blade',
|
||||
'render_markup' => '<div>Hello {{ $config["name"] }}</div>',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class);
|
||||
});
|
||||
|
||||
it('exports and imports plugin maintaining all data', function () {
|
||||
$user = User::factory()->create();
|
||||
$originalPlugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Round Trip Plugin',
|
||||
'trmnlp_id' => 'round-trip-789',
|
||||
'data_stale_minutes' => 45,
|
||||
'data_strategy' => 'static',
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div>Hello {{ config.name }}!</div>',
|
||||
'configuration_template' => [
|
||||
'custom_fields' => [
|
||||
[
|
||||
'keyname' => 'name',
|
||||
'field_type' => 'text',
|
||||
'default' => 'Test User',
|
||||
],
|
||||
[
|
||||
'keyname' => 'color',
|
||||
'field_type' => 'select',
|
||||
'default' => 'blue',
|
||||
'options' => ['red', 'green', 'blue'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'data_payload' => ['items' => [1, 2, 3]],
|
||||
]);
|
||||
|
||||
// Export the plugin
|
||||
$exporter = app(PluginExportService::class);
|
||||
$exportResponse = $exporter->exportToZip($originalPlugin, $user);
|
||||
|
||||
// Get the exported file path
|
||||
$exportedFilePath = $exportResponse->getFile()->getPathname();
|
||||
|
||||
// Create an UploadedFile from the exported ZIP
|
||||
$uploadedFile = new UploadedFile(
|
||||
$exportedFilePath,
|
||||
'plugin_round-trip-789.zip',
|
||||
'application/zip',
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Import the plugin back
|
||||
$importer = app(PluginImportService::class);
|
||||
$importedPlugin = $importer->importFromZip($uploadedFile, $user);
|
||||
|
||||
// Verify the imported plugin has the same data
|
||||
expect($importedPlugin->name)->toBe('Round Trip Plugin');
|
||||
expect($importedPlugin->trmnlp_id)->toBe('round-trip-789');
|
||||
expect($importedPlugin->data_stale_minutes)->toBe(45);
|
||||
expect($importedPlugin->data_strategy)->toBe('static');
|
||||
expect($importedPlugin->markup_language)->toBe('liquid');
|
||||
expect($importedPlugin->render_markup)->toContain('Hello {{ config.name }}!');
|
||||
expect($importedPlugin->configuration_template['custom_fields'])->toHaveCount(2);
|
||||
expect($importedPlugin->data_payload)->toBe(['items' => [1, 2, 3]]);
|
||||
});
|
||||
|
||||
it('handles blade templates correctly', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Blade Plugin',
|
||||
'trmnlp_id' => 'blade-plugin-101',
|
||||
'markup_language' => 'blade',
|
||||
'render_markup' => '<div>Hello {{ $config["name"] }}!</div>',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class);
|
||||
});
|
||||
|
||||
it('removes wrapper div from exported markup', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Wrapped Plugin',
|
||||
'trmnlp_id' => 'wrapped-plugin-202',
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div class="view view--{{ size }}">Hello World</div>',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class);
|
||||
});
|
||||
|
||||
it('converts polling headers correctly', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Header Plugin',
|
||||
'trmnlp_id' => 'header-plugin-303',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_header' => 'Authorization: Bearer token',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class);
|
||||
});
|
||||
|
||||
it('api route returns zip file for authenticated user', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'API Test Plugin',
|
||||
'trmnlp_id' => 'api-test-404',
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div>API Test</div>',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'application/zip');
|
||||
$response->assertHeader('Content-Disposition', 'attachment; filename=plugin_api-test-404.zip');
|
||||
});
|
||||
|
||||
it('api route returns 404 for non-existent plugin', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/plugin_settings/non-existent-id/archive');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
it('api route returns 401 for unauthenticated user', function () {
|
||||
$response = $this->getJson('/api/plugin_settings/test-id/archive');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
it('api route returns 404 for plugin belonging to different user', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user1->id,
|
||||
'trmnlp_id' => 'other-user-plugin',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user2)
|
||||
->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
it('exports zip with files in root directory', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Root Directory Test',
|
||||
'trmnlp_id' => 'root-test-123',
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div>Test content</div>',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
$zipPath = $response->getFile()->getPathname();
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath);
|
||||
|
||||
// Check that files are in the root, not in src/
|
||||
expect($zip->locateName('settings.yml'))->not->toBeFalse();
|
||||
expect($zip->locateName('full.liquid'))->not->toBeFalse();
|
||||
expect($zip->locateName('src/settings.yml'))->toBeFalse();
|
||||
expect($zip->locateName('src/full.liquid'))->toBeFalse();
|
||||
|
||||
$zip->close();
|
||||
});
|
||||
|
||||
it('maintains correct yaml field order', function () {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'YAML Order Test',
|
||||
'trmnlp_id' => 'yaml-order-test',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => 'https://api.example.com/data',
|
||||
'polling_verb' => 'post',
|
||||
'data_stale_minutes' => 30,
|
||||
'markup_language' => 'liquid',
|
||||
'render_markup' => '<div>Test</div>',
|
||||
]);
|
||||
|
||||
$exporter = app(PluginExportService::class);
|
||||
$response = $exporter->exportToZip($plugin, $user);
|
||||
|
||||
$zipPath = $response->getFile()->getPathname();
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath);
|
||||
|
||||
// Extract and read the settings.yml file
|
||||
$zip->extractTo(sys_get_temp_dir(), 'settings.yml');
|
||||
$yamlContent = file_get_contents(sys_get_temp_dir() . '/settings.yml');
|
||||
$zip->close();
|
||||
|
||||
// Check that the YAML content has the expected field order
|
||||
$expectedOrder = [
|
||||
'name:',
|
||||
'no_screen_padding:',
|
||||
'dark_mode:',
|
||||
'strategy:',
|
||||
'static_data:',
|
||||
'polling_verb:',
|
||||
'polling_url:',
|
||||
'refresh_interval:',
|
||||
'id:',
|
||||
'custom_fields:',
|
||||
];
|
||||
|
||||
$lines = explode("\n", $yamlContent);
|
||||
$fieldLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^([a-zA-Z_]+):/', $line, $matches)) {
|
||||
$fieldLines[] = $matches[1] . ':';
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the fields appear in the expected order (allowing for missing optional fields)
|
||||
$currentIndex = 0;
|
||||
foreach ($expectedOrder as $expectedField) {
|
||||
$foundIndex = array_search($expectedField, $fieldLines);
|
||||
if ($foundIndex !== false) {
|
||||
expect($foundIndex)->toBeGreaterThanOrEqual($currentIndex);
|
||||
$currentIndex = $foundIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
unlink(sys_get_temp_dir() . '/settings.yml');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue