diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
index c4b45c8..c791333 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
-use App\Services\ImageGenerationService;
use App\Services\Plugin\Parsers\ResponseParserRegistry;
use App\Services\PluginImportService;
use Carbon\Carbon;
@@ -25,6 +24,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
+use InvalidArgumentException;
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Extensions\StandardExtension;
@@ -455,7 +455,7 @@ class Plugin extends Model
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
{
if ($this->plugin_type !== 'recipe') {
- throw new \InvalidArgumentException('Render method is only applicable for recipe plugins.');
+ throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
}
if ($this->render_markup) {
@@ -565,17 +565,30 @@ class Plugin extends Model
if ($this->render_markup_view) {
if ($standalone) {
- return view('trmnl-layouts.single', [
+ $renderedView = view($this->render_markup_view, [
+ 'size' => $size,
+ 'data' => $this->data_payload,
+ 'config' => $this->configuration ?? [],
+ ])->render();
+
+ if ($size === 'full') {
+ return view('trmnl-layouts.single', [
+ 'colorDepth' => $device?->colorDepth(),
+ 'deviceVariant' => $device?->deviceVariant() ?? 'og',
+ 'noBleed' => $this->no_bleed,
+ 'darkMode' => $this->dark_mode,
+ 'scaleLevel' => $device?->scaleLevel(),
+ 'slot' => $renderedView,
+ ])->render();
+ }
+
+ return view('trmnl-layouts.mashup', [
+ 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
- 'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
- 'slot' => view($this->render_markup_view, [
- 'size' => $size,
- 'data' => $this->data_payload,
- 'config' => $this->configuration ?? [],
- ])->render(),
+ 'slot' => $renderedView,
])->render();
}
@@ -606,4 +619,61 @@ class Plugin extends Model
default => '1Tx1B',
};
}
+
+ /**
+ * Duplicate the plugin, copying all attributes and handling render_markup_view
+ *
+ * @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
+ * @return Plugin The newly created duplicate plugin
+ */
+ public function duplicate(?int $userId = null): self
+ {
+ // Get all attributes except id and uuid
+ // Use toArray() to get cast values (respects JSON casts)
+ $attributes = $this->toArray();
+ unset($attributes['id'], $attributes['uuid']);
+
+ // Handle render_markup_view - copy file content to render_markup
+ if ($this->render_markup_view) {
+ try {
+ $basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
+ $paths = [
+ $basePath.'.blade.php',
+ $basePath.'.liquid',
+ ];
+
+ $fileContent = null;
+ $markupLanguage = null;
+ foreach ($paths as $path) {
+ if (file_exists($path)) {
+ $fileContent = file_get_contents($path);
+ // Determine markup language based on file extension
+ $markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
+ break;
+ }
+ }
+
+ if ($fileContent !== null) {
+ $attributes['render_markup'] = $fileContent;
+ $attributes['markup_language'] = $markupLanguage;
+ $attributes['render_markup_view'] = null;
+ } else {
+ // File doesn't exist, remove the view reference
+ $attributes['render_markup_view'] = null;
+ }
+ } catch (Exception $e) {
+ // If file reading fails, remove the view reference
+ $attributes['render_markup_view'] = null;
+ }
+ }
+
+ // Append " (Copy)" to the name
+ $attributes['name'] = $this->name.' (Copy)';
+
+ // Set user_id - use provided userId or fall back to original plugin's user_id
+ $attributes['user_id'] = $userId ?? $this->user_id;
+
+ // Create and return the new plugin
+ return self::create($attributes);
+ }
}
diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php
index 9207e3e..eeb5835 100644
--- a/app/Services/PluginImportService.php
+++ b/app/Services/PluginImportService.php
@@ -17,6 +17,32 @@ use ZipArchive;
class PluginImportService
{
+ /**
+ * Validate YAML settings
+ *
+ * @param array $settings The parsed YAML settings
+ * @throws Exception
+ */
+ private function validateYAML(array $settings): void
+ {
+ if (!isset($settings['custom_fields']) || !is_array($settings['custom_fields'])) {
+ return;
+ }
+
+ foreach ($settings['custom_fields'] as $field) {
+ if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
+
+ if (isset($field['default']) && str_contains($field['default'], ',')) {
+ throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
+ }
+
+ if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
+ throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
+ }
+
+ }
+ }
+ }
/**
* Import a plugin from a ZIP file
*
@@ -58,6 +84,7 @@ class PluginImportService
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
+ $this->validateYAML($settings);
// Read full.liquid content
$fullLiquid = File::get($filePaths['fullLiquidPath']);
@@ -144,11 +171,12 @@ class PluginImportService
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
* @param string|null $iconUrl Optional icon URL to set on the plugin
+ * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
* @return Plugin The created plugin instance
*
* @throws Exception If the ZIP file is invalid or required files are missing
*/
- public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
+ public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
{
// Download the ZIP file
$response = Http::timeout(60)->get($zipUrl);
@@ -187,6 +215,7 @@ class PluginImportService
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
+ $this->validateYAML($settings);
// Read full.liquid content
$fullLiquid = File::get($filePaths['fullLiquidPath']);
@@ -217,17 +246,26 @@ class PluginImportService
'custom_fields' => $settings['custom_fields'],
];
- $plugin_updated = isset($settings['id'])
+ // Determine the trmnlp_id to use
+ $trmnlpId = $settings['id'] ?? Uuid::v7();
+
+ // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
+ if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
+ $trmnlpId = Uuid::v7();
+ }
+
+ $plugin_updated = ! $allowDuplicate && 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, 'trmnlp_id' => $trmnlpId,
],
[
'user_id' => $user->id,
'name' => $settings['name'] ?? 'Imported Plugin',
- 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
+ 'trmnlp_id' => $trmnlpId,
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
'data_strategy' => $settings['strategy'] ?? 'static',
'polling_url' => $settings['polling_url'] ?? null,
diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php
index 7257ab0..fdf7f34 100644
--- a/resources/views/livewire/catalog/index.blade.php
+++ b/resources/views/livewire/catalog/index.blade.php
@@ -113,7 +113,8 @@ class extends Component
auth()->user(),
$plugin['zip_entry_path'] ?? null,
null,
- $plugin['logo_url'] ?? null
+ $plugin['logo_url'] ?? null,
+ allowDuplicate: true
);
$this->dispatch('plugin-installed');
diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php
index 9ecad1a..cc8b070 100644
--- a/resources/views/livewire/catalog/trmnl.blade.php
+++ b/resources/views/livewire/catalog/trmnl.blade.php
@@ -164,7 +164,8 @@ class extends Component
auth()->user(),
null,
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
- $recipe['icon_url'] ?? null
+ $recipe['icon_url'] ?? null,
+ allowDuplicate: true
);
$this->dispatch('plugin-installed');
diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php
new file mode 100644
index 0000000..7aaacbb
--- /dev/null
+++ b/resources/views/livewire/plugins/config-modal.blade.php
@@ -0,0 +1,516 @@
+ loadData();
+ }
+
+ public function loadData(): void
+ {
+ $this->resetErrorBag();
+ // Reload data
+ $this->plugin = $this->plugin->fresh();
+
+ $this->configuration_template = $this->plugin->configuration_template ?? [];
+ $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
+
+ // Initialize multiValues by exploding the CSV strings from the DB
+ foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
+ if (($field['field_type'] ?? null) === 'multi_string') {
+ $fieldKey = $field['keyname'];
+ $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? '');
+
+ $currentValue = is_array($rawValue) ? '' : (string)$rawValue;
+
+ $this->multiValues[$fieldKey] = $currentValue !== ''
+ ? array_values(array_filter(explode(',', $currentValue)))
+ : [''];
+ }
+ }
+ }
+
+ /**
+ * Triggered by @close on the modal to discard any typed but unsaved changes
+ */
+ public int $resetIndex = 0; // Add this property
+ public function resetForm(): void
+ {
+ $this->loadData();
+ $this->resetIndex++; // Increment to force DOM refresh
+ }
+
+ public function saveConfiguration()
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ // final validation layer
+ $this->validate([
+ 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
+ ], [
+ 'multiValues.*.*.regex' => 'Items cannot contain commas.',
+ ]);
+
+ // Prepare config copy to send to db
+ $finalValues = $this->configuration;
+ foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
+ $fieldKey = $field['keyname'];
+
+ // Handle multi_string: Join array back to CSV string
+ if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) {
+ $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
+ }
+
+ // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior)
+ if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) {
+ $decoded = json_decode($finalValues[$fieldKey], true);
+ if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
+ $finalValues[$fieldKey] = $decoded;
+ }
+ }
+ }
+
+ // send to db
+ $this->plugin->update(['configuration' => $finalValues]);
+ $this->configuration = $finalValues; // update local state
+ $this->dispatch('config-updated'); // notifies listeners
+ Flux::modal('configuration-modal')->close();
+ }
+
+ // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------
+ public function addMultiItem(string $fieldKey): void
+ {
+ $this->multiValues[$fieldKey][] = '';
+ }
+
+ public function removeMultiItem(string $fieldKey, int $index): void
+ {
+ unset($this->multiValues[$fieldKey][$index]);
+
+ $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]);
+
+ if (empty($this->multiValues[$fieldKey])) {
+ $this->multiValues[$fieldKey][] = '';
+ }
+ }
+
+ // Livewire magic method to validate MultiValue input boxes
+ // Runs on every debounce
+ public function updatedMultiValues($value, $key)
+ {
+ $this->validate([
+ 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
+ ], [
+ 'multiValues.*.*.regex' => 'Items cannot contain commas.',
+ ]);
+ }
+
+ public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
+ {
+ abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
+
+ try {
+ $requestData = [];
+ if ($query !== null) {
+ $requestData = [
+ 'function' => $fieldKey,
+ 'query' => $query
+ ];
+ }
+
+ $response = $query !== null
+ ? Http::post($endpoint, $requestData)
+ : Http::post($endpoint);
+
+ if ($response->successful()) {
+ $this->xhrSelectOptions[$fieldKey] = $response->json();
+ } else {
+ $this->xhrSelectOptions[$fieldKey] = [];
+ }
+ } catch (\Exception $e) {
+ $this->xhrSelectOptions[$fieldKey] = [];
+ }
+ }
+
+ public function searchXhrSelect(string $fieldKey, string $endpoint): void
+ {
+ $query = $this->searchQueries[$fieldKey] ?? '';
+ if (!empty($query)) {
+ $this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
+ }
+ }
+};?>
+
+