mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
add error handling for preview fix idx bug and add tests fix light mode styling and remove transitions add global styling class
641 lines
24 KiB
PHP
641 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
|
use App\Liquid\Filters\Data;
|
|
use App\Liquid\Filters\Date;
|
|
use App\Liquid\Filters\Localization;
|
|
use App\Liquid\Filters\Numbers;
|
|
use App\Liquid\Filters\StandardFilters;
|
|
use App\Liquid\Filters\StringMarkup;
|
|
use App\Liquid\Filters\Uniqueness;
|
|
use App\Liquid\Tags\TemplateTag;
|
|
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
|
use App\Services\PluginImportService;
|
|
use Carbon\Carbon;
|
|
use Exception;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\App;
|
|
use Illuminate\Support\Facades\Blade;
|
|
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;
|
|
|
|
class Plugin extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $guarded = ['id'];
|
|
|
|
protected $casts = [
|
|
'data_payload' => 'json',
|
|
'data_payload_updated_at' => 'datetime',
|
|
'is_native' => 'boolean',
|
|
'markup_language' => 'string',
|
|
'configuration' => 'json',
|
|
'configuration_template' => 'json',
|
|
'no_bleed' => 'boolean',
|
|
'dark_mode' => 'boolean',
|
|
'preferred_renderer' => 'string',
|
|
'plugin_type' => 'string',
|
|
];
|
|
|
|
protected static function boot()
|
|
{
|
|
parent::boot();
|
|
|
|
static::creating(function ($model): void {
|
|
if (empty($model->uuid)) {
|
|
$model->uuid = Str::uuid();
|
|
}
|
|
});
|
|
|
|
static::updating(function ($model): void {
|
|
// Reset image cache when markup changes
|
|
if ($model->isDirty('render_markup')) {
|
|
$model->current_image = null;
|
|
}
|
|
});
|
|
|
|
// Sanitize configuration template on save
|
|
static::saving(function ($model): void {
|
|
$model->sanitizeTemplate();
|
|
});
|
|
}
|
|
|
|
public function user()
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
|
protected function sanitizeTemplate(): void
|
|
{
|
|
$template = $this->configuration_template;
|
|
|
|
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
|
|
foreach ($template['custom_fields'] as &$field) {
|
|
if (isset($field['description'])) {
|
|
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
|
|
}
|
|
if (isset($field['help_text'])) {
|
|
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
|
|
}
|
|
}
|
|
|
|
$this->configuration_template = $template;
|
|
}
|
|
}
|
|
|
|
public function hasMissingRequiredConfigurationFields(): bool
|
|
{
|
|
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($this->configuration_template['custom_fields'] as $field) {
|
|
// Skip fields as they are informational only
|
|
if ($field['field_type'] === 'author_bio') {
|
|
continue;
|
|
}
|
|
|
|
if ($field['field_type'] === 'copyable') {
|
|
continue;
|
|
}
|
|
|
|
if ($field['field_type'] === 'copyable_webhook_url') {
|
|
continue;
|
|
}
|
|
|
|
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
|
|
|
// Check if field is required (not marked as optional)
|
|
$isRequired = ! isset($field['optional']) || $field['optional'] !== true;
|
|
|
|
if ($isRequired) {
|
|
$currentValue = $this->configuration[$fieldKey] ?? null;
|
|
|
|
// If the field has a default value and no current value is set, it's not missing
|
|
if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
|
|
return true; // Found a required field that is not set and has no default
|
|
}
|
|
}
|
|
}
|
|
|
|
return false; // All required fields are set
|
|
}
|
|
|
|
public function isDataStale(): bool
|
|
{
|
|
// Image webhook plugins don't use data staleness - images are pushed directly
|
|
if ($this->plugin_type === 'image_webhook') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->data_strategy === 'webhook') {
|
|
// Treat as stale if any webhook event has occurred in the past hour
|
|
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
|
}
|
|
if (! $this->data_payload_updated_at || ! $this->data_stale_minutes) {
|
|
return true;
|
|
}
|
|
|
|
return $this->data_payload_updated_at->addMinutes($this->data_stale_minutes)->isPast();
|
|
}
|
|
|
|
public function updateDataPayload(): void
|
|
{
|
|
if ($this->data_strategy !== 'polling' || !$this->polling_url) {
|
|
return;
|
|
}
|
|
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
|
|
|
// resolve headers
|
|
if ($this->polling_header) {
|
|
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
|
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
|
foreach ($headerLines as $line) {
|
|
$parts = explode(':', $line, 2);
|
|
if (count($parts) === 2) {
|
|
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolve and clean URLs
|
|
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
|
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
|
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
|
fn ($url): bool => filled($url)
|
|
));
|
|
|
|
$combinedResponse = [];
|
|
|
|
// Loop through all URLs (Handles 1 or many)
|
|
foreach ($urls as $index => $url) {
|
|
$httpRequest = Http::withHeaders($headers);
|
|
|
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
|
$httpRequest = $httpRequest->withBody($resolvedBody);
|
|
}
|
|
|
|
try {
|
|
$httpResponse = ($this->polling_verb === 'post')
|
|
? $httpRequest->post($url)
|
|
: $httpRequest->get($url);
|
|
|
|
$response = $this->parseResponse($httpResponse);
|
|
|
|
// Nest if it's a sequential array
|
|
if (array_keys($response) === range(0, count($response) - 1)) {
|
|
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
|
} else {
|
|
$combinedResponse["IDX_{$index}"] = $response;
|
|
}
|
|
} catch (Exception $e) {
|
|
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
|
|
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
|
}
|
|
}
|
|
|
|
// unwrap IDX_0 if only one URL
|
|
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
|
|
|
|
$this->update([
|
|
'data_payload' => $finalPayload,
|
|
'data_payload_updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
private function parseResponse(Response $httpResponse): array
|
|
{
|
|
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
|
|
|
foreach ($parsers as $parser) {
|
|
$parserName = class_basename($parser);
|
|
|
|
try {
|
|
$result = $parser->parse($httpResponse);
|
|
|
|
if ($result !== null) {
|
|
return $result;
|
|
}
|
|
} catch (Exception $e) {
|
|
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
|
}
|
|
}
|
|
|
|
return ['error' => 'Failed to parse response'];
|
|
}
|
|
|
|
/**
|
|
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
|
|
*/
|
|
private function applyLiquidReplacements(string $template): string
|
|
{
|
|
|
|
$replacements = [];
|
|
|
|
// Apply basic replacements
|
|
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
|
|
|
|
// Convert Ruby/strftime date formats to PHP date formats
|
|
$template = $this->convertDateFormats($template);
|
|
|
|
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
|
|
$template = preg_replace(
|
|
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
|
'{% render $1, ',
|
|
$template
|
|
);
|
|
|
|
// Convert for loops with filters to use temporary variables
|
|
// This handles: {% for item in collection | filter: "key", "value" %}
|
|
// Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %}
|
|
$template = preg_replace_callback(
|
|
'/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/',
|
|
function (array $matches): string {
|
|
$variableName = mb_trim($matches[1]);
|
|
$collection = mb_trim($matches[2]);
|
|
$filter = mb_trim($matches[3]);
|
|
$tempVarName = '_temp_'.uniqid();
|
|
|
|
return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}";
|
|
},
|
|
(string) $template
|
|
);
|
|
|
|
return $template;
|
|
}
|
|
|
|
/**
|
|
* Convert Ruby/strftime date formats to PHP date formats in Liquid templates
|
|
*/
|
|
private function convertDateFormats(string $template): string
|
|
{
|
|
// Handle date filter formats: date: "format" or date: 'format'
|
|
$template = preg_replace_callback(
|
|
'/date:\s*(["\'])([^"\']+)\1/',
|
|
function (array $matches): string {
|
|
$quote = $matches[1];
|
|
$format = $matches[2];
|
|
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
|
|
|
return 'date: '.$quote.$convertedFormat.$quote;
|
|
},
|
|
$template
|
|
);
|
|
|
|
// Handle l_date filter formats: l_date: "format" or l_date: 'format'
|
|
$template = preg_replace_callback(
|
|
'/l_date:\s*(["\'])([^"\']+)\1/',
|
|
function (array $matches): string {
|
|
$quote = $matches[1];
|
|
$format = $matches[2];
|
|
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
|
|
|
return 'l_date: '.$quote.$convertedFormat.$quote;
|
|
},
|
|
(string) $template
|
|
);
|
|
|
|
return $template;
|
|
}
|
|
|
|
/**
|
|
* Check if a template contains a Liquid for loop pattern
|
|
*
|
|
* @param string $template The template string to check
|
|
* @return bool True if the template contains a for loop pattern
|
|
*/
|
|
private function containsLiquidForLoop(string $template): bool
|
|
{
|
|
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
|
|
}
|
|
|
|
/**
|
|
* Resolve Liquid variables in a template string using the Liquid template engine
|
|
*
|
|
* Uses the external trmnl-liquid renderer when:
|
|
* - preferred_renderer is 'trmnl-liquid'
|
|
* - External renderer is enabled in config
|
|
* - Template contains a Liquid for loop pattern
|
|
*
|
|
* Otherwise uses the internal PHP-based Liquid renderer.
|
|
*
|
|
* @param string $template The template string containing Liquid variables
|
|
* @return string The resolved template with variables replaced with their values
|
|
*
|
|
* @throws LiquidException
|
|
* @throws Exception
|
|
*/
|
|
public function resolveLiquidVariables(string $template): string
|
|
{
|
|
// Get configuration variables - make them available at root level
|
|
$variables = $this->configuration ?? [];
|
|
|
|
// Check if external renderer should be used
|
|
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
|
|
&& config('services.trmnl.liquid_enabled')
|
|
&& $this->containsLiquidForLoop($template);
|
|
|
|
if ($useExternalRenderer) {
|
|
// Use external Ruby liquid renderer
|
|
return $this->renderWithExternalLiquidRenderer($template, $variables);
|
|
}
|
|
|
|
// Use the Liquid template engine to resolve variables
|
|
$environment = App::make('liquid.environment');
|
|
$environment->filterRegistry->register(StandardFilters::class);
|
|
$liquidTemplate = $environment->parseString($template);
|
|
$context = $environment->newRenderContext(data: $variables);
|
|
|
|
return $liquidTemplate->render($context);
|
|
}
|
|
|
|
/**
|
|
* Render template using external Ruby liquid renderer
|
|
*
|
|
* @param string $template The liquid template string
|
|
* @param array $context The render context data
|
|
* @return string The rendered HTML
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
private function renderWithExternalLiquidRenderer(string $template, array $context): string
|
|
{
|
|
$liquidPath = config('services.trmnl.liquid_path');
|
|
|
|
if (empty($liquidPath)) {
|
|
throw new Exception('External liquid renderer path is not configured');
|
|
}
|
|
|
|
// HTML encode the template
|
|
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
|
|
|
// Encode context as JSON
|
|
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
if ($jsonContext === false) {
|
|
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
|
|
}
|
|
|
|
// Validate argument sizes
|
|
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
|
|
|
|
// Execute the external renderer
|
|
$process = Process::run([
|
|
$liquidPath,
|
|
'--template',
|
|
$encodedTemplate,
|
|
'--context',
|
|
$jsonContext,
|
|
]);
|
|
|
|
if (! $process->successful()) {
|
|
$errorOutput = $process->errorOutput() ?: $process->output();
|
|
throw new Exception('External liquid renderer failed: '.$errorOutput);
|
|
}
|
|
|
|
return $process->output();
|
|
}
|
|
|
|
/**
|
|
* Render the plugin's markup
|
|
*
|
|
* @throws LiquidException
|
|
*/
|
|
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.');
|
|
}
|
|
|
|
if ($this->render_markup) {
|
|
$renderedContent = '';
|
|
|
|
if ($this->markup_language === 'liquid') {
|
|
// Get timezone from user or fall back to app timezone
|
|
$timezone = $this->user->timezone ?? config('app.timezone');
|
|
|
|
// Calculate UTC offset in seconds
|
|
$utcOffset = (string) Carbon::now($timezone)->getOffset();
|
|
|
|
// Build render context
|
|
$context = [
|
|
'size' => $size,
|
|
'data' => $this->data_payload,
|
|
'config' => $this->configuration ?? [],
|
|
...(is_array($this->data_payload) ? $this->data_payload : []),
|
|
'trmnl' => [
|
|
'system' => [
|
|
'timestamp_utc' => now()->utc()->timestamp,
|
|
],
|
|
'user' => [
|
|
'utc_offset' => $utcOffset,
|
|
'name' => $this->user->name ?? 'Unknown User',
|
|
'locale' => 'en',
|
|
'time_zone_iana' => $timezone,
|
|
],
|
|
'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 : []),
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
// Check if external renderer should be used
|
|
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
|
// Use external Ruby renderer - pass raw template without preprocessing
|
|
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
|
} else {
|
|
// Use PHP keepsuit/liquid renderer
|
|
// Create a custom environment with inline templates support
|
|
$inlineFileSystem = new InlineTemplatesFileSystem();
|
|
$environment = new \Keepsuit\Liquid\Environment(
|
|
fileSystem: $inlineFileSystem,
|
|
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
|
|
);
|
|
|
|
// Register all custom filters
|
|
$environment->filterRegistry->register(Data::class);
|
|
$environment->filterRegistry->register(Date::class);
|
|
$environment->filterRegistry->register(Localization::class);
|
|
$environment->filterRegistry->register(Numbers::class);
|
|
$environment->filterRegistry->register(StringMarkup::class);
|
|
$environment->filterRegistry->register(Uniqueness::class);
|
|
|
|
// Register the template tag for inline templates
|
|
$environment->tagRegistry->register(TemplateTag::class);
|
|
|
|
// Apply Liquid replacements (including 'with' syntax conversion)
|
|
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
|
|
|
$template = $environment->parseString($processedMarkup);
|
|
$liquidContext = $environment->newRenderContext(data: $context);
|
|
$renderedContent = $template->render($liquidContext);
|
|
}
|
|
} else {
|
|
$renderedContent = Blade::render($this->render_markup, [
|
|
'size' => $size,
|
|
'data' => $this->data_payload,
|
|
'config' => $this->configuration ?? [],
|
|
]);
|
|
}
|
|
|
|
if ($standalone) {
|
|
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' => $renderedContent,
|
|
])->render();
|
|
}
|
|
|
|
return view('trmnl-layouts.mashup', [
|
|
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
|
'colorDepth' => $device?->colorDepth(),
|
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
|
'darkMode' => $this->dark_mode,
|
|
'scaleLevel' => $device?->scaleLevel(),
|
|
'slot' => $renderedContent,
|
|
])->render();
|
|
|
|
}
|
|
|
|
return $renderedContent;
|
|
}
|
|
|
|
if ($this->render_markup_view) {
|
|
if ($standalone) {
|
|
$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',
|
|
'darkMode' => $this->dark_mode,
|
|
'scaleLevel' => $device?->scaleLevel(),
|
|
'slot' => $renderedView,
|
|
])->render();
|
|
}
|
|
|
|
return view($this->render_markup_view, [
|
|
'size' => $size,
|
|
'data' => $this->data_payload,
|
|
'config' => $this->configuration ?? [],
|
|
])->render();
|
|
|
|
}
|
|
|
|
return '<p>No render markup yet defined for this plugin.</p>';
|
|
}
|
|
|
|
/**
|
|
* Get a configuration value by key
|
|
*/
|
|
public function getConfiguration(string $key, $default = null)
|
|
{
|
|
return $this->configuration[$key] ?? $default;
|
|
}
|
|
|
|
public function getPreviewMashupLayoutForSize(string $size): string
|
|
{
|
|
return match ($size) {
|
|
'half_vertical' => '1Lx1R',
|
|
'quadrant' => '2x2',
|
|
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);
|
|
}
|
|
}
|