File: /home/xedaptot/ai.naniguide.com/app/Model/Template.php
<?php
/**
* Template class.
*
* Model class for template
*
* LICENSE: This product includes software developed at
* the Acelle Co., Ltd. (http://acellemail.com/).
*
* @category MVC Model
*
* @copyright Acelle Co., Ltd
* @license Acelle Co., Ltd
*
* @version 1.0
*
* @link http://acellemail.com
*/
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
use Validator;
use App\Library\Traits\HasUid;
use KubAT\PhpSimple\HtmlDomParser;
use App\Library\Tool;
use App\Library\StringHelper;
use DOMDocument;
use Exception;
use Closure;
use League\Pipeline\PipelineBuilder;
use App\Library\HtmlHandler\TransformWidgets;
use App\Library\HtmlHandler\DecodeHtmlSpecialChars;
use App\Library\HtmlHandler\GenerateSpintax;
use App\Library\HtmlHandler\AddDoctype;
use App\Library\HtmlHandler\ParseRss;
use App\Library\HtmlHandler\MakeInlineCss;
use App\Library\Storage\StoragePathResolver;
use App\Library\Storage\StorageEngineResolver;
use App\Library\Storage\AssetPath;
use Symfony\Component\HttpFoundation\File\File;
use function App\Helpers\url_get_contents_ssl_safe;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Template extends Model
{
use HasFactory;
use HasUid;
protected static function newFactory()
{
return \Database\Factories\TemplateFactory::new();
}
/**
* Default builder JSON for new/blank templates.
* Every template MUST have valid BuilderJS JSON — empty string crashes
* the builder because blade outputs {!! $template->json !!} directly
* into JavaScript, producing `const themeJson = ;` (SyntaxError).
*/
public const DEFAULT_BUILDER_JSON = '{"name":"PageElement","template":"Page","blocks":[],"formats":{"background_color":"#FFFFFF"}}';
public const SOURCE_BUILDER = 'builder';
public const SOURCE_UPLOADED = 'uploaded';
/**
* Validate json is never empty on save.
* This is the application-level enforcement — mirrors the DB CHECK constraint
* added in migration 2026_04_14_200000.
*/
protected static function booted(): void
{
static::saving(function (Template $template) {
$json = $template->getAttributes()['json'] ?? null;
if ($json === null || $json === '' || $json === '{}') {
throw new \InvalidArgumentException(
'Template.json must be valid BuilderJS JSON — cannot be empty/null. '
. 'Use Template::DEFAULT_BUILDER_JSON for blank templates. '
. 'Got: ' . var_export($json, true)
);
}
});
}
/**
* Return json as a decoded array for safe use in controllers/views.
* Controllers should use this instead of raw $template->json to pass
* typed data to views via @json().
*/
public function getBuilderJson(): array
{
$json = $this->json;
if (empty($json)) {
$json = self::DEFAULT_BUILDER_JSON;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return json_decode(self::DEFAULT_BUILDER_JSON, true);
}
// Ensure theme key is present (BuilderJS requires it)
if (empty($decoded['theme'])) {
$decoded['theme'] = $this->theme ?: 'default';
}
return $decoded;
}
public const BASE_PATH = 'themes';
public static function getPath(): string
{
return storage_path(self::BASE_PATH);
}
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'uid', 'name', 'content', 'theme', 'type', 'json', 'source'
];
public function isUploaded(): bool
{
return $this->source === self::SOURCE_UPLOADED;
}
/**
* Items per page.
*
* @var array
*/
public static $itemsPerPage = 25;
/**
* Associations.
*
* @var object | collect
*/
public function customer()
{
return $this->belongsTo('App\Model\Customer');
}
public function admin()
{
return $this->belongsTo('App\Model\Admin');
}
/**
* The template that belong to the categories.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<TemplateCategory, $this>
*/
public function categories(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(TemplateCategory::class, 'templates_categories', 'template_id', 'category_id');
}
/**
* Search.
*
* @return collect
*/
public function scopeCategoryUid($query, $uid)
{
$category = \App\Model\TemplateCategory::findByUid($uid);
// Category
if ($category) {
$query = $query->whereHas('categories', function ($q) use ($category) {
$q->whereIn('template_categories.id', [$category->id]);
});
}
}
/**
* Search.
*
* @return collect
*/
public function scopeSearch($query, $keyword)
{
// Keyword
if (!empty($keyword)) {
$query = $query->where('name', 'like', '%'.trim($keyword).'%');
}
}
/**
* Customer templates.
*
* @return collect
*/
public function scopeCustom($query)
{
$query = $query->whereNotNull('customer_id');
}
/**
* Public/Gallery templates.
*
* @return collect
*/
public static function scopeShared($query)
{
$query = $query->whereNull('customer_id');
}
public static function scopeEmail($query)
{
$query = $query->where('type', '=', self::TYPE_EMAIL);
}
public static function scopePopup($query)
{
$query = $query->where('type', '=', self::TYPE_POPUP);
}
/**
* Template tags.
*
* All availabel template tags
*/
public static function tags($list = null)
{
$tags = [];
$tags[] = ['name' => 'SUBSCRIBER_EMAIL', 'required' => false];
// List field tags
if (isset($list)) {
foreach ($list->fields as $field) {
if ($field->tag != 'EMAIL') {
$tags[] = ['name' => 'SUBSCRIBER_'.$field->tag, 'required' => false];
}
}
}
$tags = array_merge($tags, [
['name' => 'UNSUBSCRIBE_URL', 'required' => false],
['name' => 'SUBSCRIBER_UID', 'required' => false],
['name' => 'WEB_VIEW_URL', 'required' => false],
['name' => 'UPDATE_PROFILE_URL', 'required' => false],
['name' => 'CAMPAIGN_NAME', 'required' => false],
['name' => 'CAMPAIGN_UID', 'required' => false],
['name' => 'CAMPAIGN_SUBJECT', 'required' => false],
['name' => 'CAMPAIGN_FROM_EMAIL', 'required' => false],
['name' => 'CAMPAIGN_FROM_NAME', 'required' => false],
['name' => 'CAMPAIGN_REPLY_TO', 'required' => false],
['name' => 'CURRENT_YEAR', 'required' => false],
['name' => 'CURRENT_MONTH', 'required' => false],
['name' => 'CURRENT_DAY', 'required' => false],
['name' => 'LIST_NAME', 'required' => false],
['name' => 'LIST_FROM_NAME', 'required' => false],
['name' => 'LIST_FROM_EMAIL', 'required' => false],
]);
return $tags;
}
/**
* Contain category
*
* @return bool
*/
public function hasCategory($category)
{
return $this->categories()->where('template_categories.id', $category->id)->exists();
}
/**
* Add category
*
* @return void
*/
public function addCategory($category)
{
if (!$this->hasCategory($category)) {
$this->categories()->attach($category->id);
}
}
/**
* Remove category
*
* @return void
*/
public function removeCategory($category)
{
if ($this->hasCategory($category)) {
$this->categories()->detach($category->id);
}
}
/**
* Copy new template.
*/
public function copy($name = null)
{
$copy = new self();
// UID and Customer ID must be present first, in order to create directory and to transformURL
$copy->generateUid();
// Copy content
$copy->content = $this->content;
$copy->json = $this->json;
// copy theme
$copy->theme = $this->theme;
// Preserve source (builder vs uploaded)
$copy->source = $this->source ?: self::SOURCE_BUILDER;
// Copy flags
$copy->is_default = false; // no longer default
// Overwrite attributes :name
$copy->name = $name ?? $this->name;
// Thumbnail — skip silently when source is missing (uploaded templates have none).
$thumbSource = storage_path(join_paths('app', $this->getThumbPath()));
if (file_exists($thumbSource)) {
$copy->updateThumbnailFromPath($thumbSource);
}
// Then save, generate UID, created_at, updated_at
$copy->save();
// Important: save before adding
foreach ($this->categories as $category) {
$copy->addCategory($category);
}
// return
return $copy;
}
// Private means a template that is associated to a campaign, email or form
public function copyAsPrivate($attributes)
{
$copy = $this->copy($attributes['name']);
$copy->save();
return $copy;
}
// Actual URL prefix
public function getUrl($path = null): string
{
return app(\App\Services\UrlService::class)->generateThemeAssetUrl($this->theme ?: 'default', $path);
}
public function getThemeMetadata()
{
$theme = $this->theme ?: 'default';
// Use fixed storage local here, do not resolve @important
$storage = app(\App\Library\Storage\StorageEngineResolver::class)->resolve('local');
$metadataFile = "themes/{$theme}/index.json";
$path = AssetPath::from($metadataFile);
if ($storage->fileExists($path)) {
return json_decode($storage->getFileContent($path), true);
}
// Fallback: check resources/themes/ for built-in themes
$resourcePath = resource_path("themes/{$theme}/index.json");
if (file_exists($resourcePath)) {
return json_decode(file_get_contents($resourcePath), true);
}
throw new \Exception("Cannot find theme metadata file at: {$metadataFile}");
}
public function getUrlPrefix()
{
// @important $path = null means prefix
return $this->getUrl($path = null);
}
public function updateThumbnailFromPath($sourcePath)
{
if (!file_exists($sourcePath)) {
throw new \RuntimeException("Thumbnail source file not found: {$sourcePath}");
}
$storagePath = AssetPath::from($this->getThumbPath());
$dir = storage_path('app/' . $storagePath->getPath());
if (!is_dir($dir) && !mkdir($dir, 0775, true)) {
throw new \RuntimeException("Cannot create thumbnail directory: {$dir}");
}
if (!is_writable($dir)) {
throw new \RuntimeException("Thumbnail directory not writable: {$dir}");
}
$sourceFile = new File($sourcePath);
$storage = app(\App\Library\Storage\StorageInterface::class);
$storage->store($sourceFile, $storagePath);
}
public function getThumbPath()
{
// Full path like: public/template/thumb/{uid}
return app(StoragePathResolver::class)->templateThumbPath($this);
}
public function makeThumbUrl($checkIfFileExists = false): ?string
{
$asset = AssetPath::from($this->getThumbPath());
return app(\App\Services\UrlService::class)->makePublicUrl($asset);
}
public function getThumbUrl()
{
$storage = app(\App\Library\Storage\StorageInterface::class);
$asset = AssetPath::from($this->getThumbPath());
if (!$storage->fileExists($asset)) {
return url('images/placeholder.jpg');
}
// Cache-bust from updated_at so that when the thumbnail file is
// overwritten (e.g. Change Theme copies a new one in place), browsers
// refetch instead of rendering the stale cached image.
$url = $this->makeThumbUrl();
$version = optional($this->updated_at)->timestamp ?: time();
return $url . (str_contains($url, '?') ? '&' : '?') . 'v=' . $version;
}
public function findCssFiles()
{
// IMPORTANT
// + No external CSS
// + Only CSS in the template folder is considered
$files = [];
$document = new DOMDocument();
$document->loadHTML($this->content, LIBXML_NOWARNING | LIBXML_NOERROR);
$links = $document->getElementsByTagName('link');
foreach ($links as $link) {
$href = $link->getAttribute('href');
if (!empty($href)) {
$path = $this->getAssetFileFromUrl($href);
if ($path) {
$files[] = $path;
}
}
}
return $files;
}
public function getAssetFileFromUrl($url)
{
// To remove query string if any like ?search=abc
// For example
// parse_url('/assets/path/file.jpg?id=320943&search=', PHP_URL_PATH)
// Returns
// /assets/path/file.jpg
$url = parse_url($url, PHP_URL_PATH);
// Clean up subdirectory, leaving the url as '/assets/path/file.jpg' only
$subdirectory = app(\App\Services\UrlService::class)->getAppSubdirectory();
if ($subdirectory) {
// Make sure $subdirectory looks like '/subdir' ==> with a leading slash but without trailing one
$subdirectory = rtrim(join_paths('/', $subdirectory), '/');
// Remove subdirectory of URL
$url = str_replace($subdirectory, '', $url);
}
// 'theme/assets/{path}/f/{subpath?}'
if (preg_match('/\/theme\/assets\/(?<path>[^\/]+)\/f\/(?<subpath>.+)/', $url, $match)) {
$theme = \App\Library\StringHelper::base64UrlDecode($match['path']);
$absPath = storage_path(join_paths('themes', $theme, $match['subpath']));
return $absPath;
}
// 'assets/{dirname}/{basename}'
elseif (preg_match('/\/assets\/(?<dirname>[^\/]+)\/(?<basename>[^\/]+)/', $url, $match)) {
$dirname = StringHelper::base64UrlDecode($match['dirname']);
$absPath = storage_path(join_paths($dirname, $match['basename']));
return $absPath;
} else {
return null;
}
}
public function wooTransform($body)
{
// find all links from contents
$document = HtmlDomParser::str_get_html($body);
// Woo Items List
foreach ($document->find('[builder-element=ProductListElement]') as $element) {
$max = $element->getAttribute('data-max-items');
$display = $element->getAttribute('data-display');
$sort = $element->getAttribute('data-sort-by');
$request = request();
$request->merge(['per_page' => $max]);
$request->merge(['sort_by' => $sort]);
$items = Product::search($request)->paginate($request->per_page)
->map(function ($product, $key) {
return [
'id' => $product->uid,
'name' => $product->title,
'price' => $product->price,
'image' => $product->getImageUrl(),
'description' => substr(strip_tags($product->description), 0, 100),
'link' => action('ProductController@index'),
];
})->toArray();
$itemsHtml = [];
foreach ($items as $item) {
// $element->find('.woo-items')[0]->innertext = 'dddddd';
$itemsHtml[] = '
<div class="woo-col-item mb-4 mt-4 col-md-' . (12 / $display) . '">
<div class="">
<div class="img-col mb-3">
<div class="d-flex align-items-center justify-content-center" style="height: 200px;">
<a style="width:100%" href="'.$item["link"].'" class="mr-4"><img width="100%" src="'.($item["image"] ? $item["image"] : url('images/cart_item.svg')).'" style="max-height:200px;max-width:100%;" /></a>
</div>
</div>
<div class="">
<p class="font-weight-normal product-name mb-1">
<a style="color: #333;" href="'.$item["link"].'" class="mr-4">'.$item["name"].'</a>
</p>
<p class=" product-description">'.$item["description"].'</p>
<p><strong>'.$item["price"].'</strong></p>
<a href="'.$item["link"].'" style="background-color: #9b5c8f;
border-color: #9b5c8f;" class="btn btn-primary text-white">
' . trans('messages.automation.view_more') . '
</a>
</div>
</div>
</div>
';
}
$element->find('.products')[0]->innertext = implode('', $itemsHtml);
}
// Woo Single Item
foreach ($document->find('[builder-element=ProductElement]') as $element) {
$productId = $element->getAttribute('product-id');
if ($productId) {
$product = Product::findByUid($productId);
$item = [
'id' => $product->uid,
'name' => $product->title,
'price' => $product->price,
'image' => $product->getImageUrl(),
'description' => substr(strip_tags($product->description), 0, 100),
'link' => action('ProductController@index'),
];
// $element->find('.product-name', 0)->innertext = $item["name"];
// $element->find('.product-description', 0)->innertext = $item["description"];
// $element->find('.product-link', 0)->href = $item["link"];
// $element->find('.product-price', 0)->innertext = $item["price"];
$element->find('.product-link img', 0)->src = $item["image"];
$html = $element->innertext;
$html = str_replace('*|PRODUCT_NAME|*', $item["name"], $html);
$html = str_replace('*|PRODUCT_DESCRIPTION|*', $item["description"], $html);
$html = str_replace('*|PRODUCT_URL|*', $item["link"], $html);
$html = str_replace('*|PRODUCT_PRICE|*', $item["price"], $html);
// $html = str_replace('*|PRODUCT_QUANTITY|*', $item["quantity"], $html);
$element->innertext = $html;
}
}
$body = $document;
return $body;
}
public function uploadAssetFromBase64($base64)
{
throw new \Exception("No longer supported");
}
public function uploadAssetFromUrl($url)
{
return $url;
/* Another way is to fetch and save the image to the local directory of the template */
}
/**
* Template tags.
*
* All availabel template tags
*/
public static function builderTags($list = null)
{
$tags = self::tags($list);
$result = [];
if (true) {
// Unsubscribe link
$result[] = [
'type' => 'label',
'text' => '<a href="{{ unsubscribe_url }}">' . trans('messages.editor.unsubscribe_text') . '</a>',
'tag' => '{UNSUBSCRIBE_LINK}',
'required' => true,
];
// web view link
$result[] = [
'type' => 'label',
'text' => '<a href="{{ web_view_url }}">' . trans('messages.editor.click_view_web_version') . '</a>',
'tag' => '{WEB_VIEW_LINK}',
'required' => true,
];
}
foreach ($tags as $tag) {
$result[] = [
'type' => 'label',
'text' => '{'.$tag['name'].'}',
'tag' => '{'.$tag['name'].'}',
'required' => true,
];
}
return $result;
}
public function deleteAndCleanup()
{
$this->delete();
$path = AssetPath::from($this->getThumbPath());
$storage = app(\App\Library\Storage\StorageInterface::class);
return $storage->delete($path);
}
public function updateContent($json, $content)
{
$rules = array(
'json' => 'required',
'content' => 'required',
);
// Validate
$validator = \Validator::make([
'json' => $json,
'content' => $content,
], $rules);
if ($validator->fails()) {
return $validator;
}
// save both json and html (content)
$this->content = $content;
$this->json = $json;
$this->save();
//
return $validator;
}
public function urlTagsDropdown()
{
return [
['value' => '{{ unsubscribe_url }}', 'text' => trans('messages.editor.unsubscribe_text')],
['value' => '{{ update_profile_url }}', 'text' => trans('messages.editor.update_profile_text')],
['value' => '{{ web_view_url }}', 'text' => trans('messages.editor.click_view_web_version')],
];
}
public function getPreviewContent()
{
// Bind subscriber/message/server information to email content
$pipeline = new PipelineBuilder();
$pipeline->add(new AddDoctype());
$pipeline->add(new MakeInlineCss($this->findCssFiles()));
$pipeline->add(new TransformWidgets());
$pipeline->add(new DecodeHtmlSpecialChars());
$pipeline->add(new GenerateSpintax());
// Actually push HTML to pipeline for processing
$html = $pipeline->build()->process($this->content);
// Return subscriber's bound html
return $html;
}
public static function defaultRssConfig()
{
return [
'url' => '',
'size' => 10,
'templates' => [
'FeedTitle' => [
'title' => trans('messages.rss.feed_title'),
'show' => true,
'template' => '@feed_title',
],
'FeedSubtitle' => [
'title' => trans('messages.rss.feed_subtitle'),
'show' => true,
'template' => 'Updated at: @feed_build_date',
],
'FeedTagdLine' => [
'title' => trans('messages.rss.feed_tagline'),
'show' => true,
'template' => trans('messages.rss.top_stories_for_you'),
],
'ItemTitle' => [
'title' => trans('messages.rss.item_title'),
'show' => true,
'template' => 'Title: @item_title',
],
'ItemMeta' => [
'title' => trans('messages.rss.meta_line'),
'show' => true,
'template' => '<img src="'.url('images/avatar1.svg').'" width="30px" style="margin-right:5px" /> something here - @item_pubdate',
],
'ItemDescription' => [
'title' => trans('messages.rss.item_description'),
'show' => true,
'template' => '@item_description <a href="@item_url">Read more</a>',
],
'ItemStats' => [
'title' => trans('messages.rss.stats_line'),
'show' => true,
'template' => '<img src="'.url('images/icon-up.svg').'" width="16px" style="margin-right:5px" /> 400k updates,
<img src="'.url('images/icon-comment.svg').'" width="16px" style="margin-right:5px" /> 1.2k comments',
],
'ItemEnclosure' => [
'title' => trans('messages.rss.enclosure'),
'show' => false,
'template' => '@item_enclosure',
],
],
];
}
/**
* Copy theme / json / content / thumbnail from another template.
* Used by the "Change Theme" flow in customer + admin builders.
*/
public function applyTemplateFrom(Template $source): void
{
$this->theme = $source->theme;
$this->json = $source->json;
$this->content = $source->content;
$this->save();
$this->copyThumbnailFrom($source);
}
/**
* Copy the thumbnail file from another template's storage path into ours.
* Silently no-ops if the source template has no thumbnail on disk.
*/
public function copyThumbnailFrom(Template $source): void
{
$storage = app(\App\Library\Storage\StorageInterface::class);
$sourceAsset = AssetPath::from($source->getThumbPath());
if (!$storage->fileExists($sourceAsset)) {
return;
}
$tmpPath = tempnam(sys_get_temp_dir(), 'tmpl_thumb_');
try {
file_put_contents($tmpPath, $storage->getFileContent($sourceAsset));
$this->updateThumbnailFromPath($tmpPath);
} finally {
if ($tmpPath && file_exists($tmpPath)) {
@unlink($tmpPath);
}
}
}
public function duplicate(): Template
{
$newTemplate = self::makeTemplate($this->theme, $this->name, $this->json, $this->content, $this->thumbnail);
// save
$newTemplate->save();
//
return $newTemplate;
}
public static function createTemplate(string $theme, string $name, string $json, string $content): Template
{
//
$template = self::makeTemplate($theme, $name, $json, $content);
// save
$template->save();
//
return $template;
}
public static function makeTemplate(string $theme, string $name, string $json, string $content): Template
{
$template = new self();
$template->theme = $theme;
$template->name = $name;
$template->is_default = true;
$template->json = $json;
$template->content = $content;
return $template;
}
/**
* Build a Template that wraps uploaded raw HTML.
*
* Why: the Email content layer is single-pathed through Template — any HTML, whether
* authored in BuilderJS or pasted as raw, lives in Template.content and runs through
* the same send pipeline. source='uploaded' marks it so re-edit UX skips BuilderJS.
* json is forced to DEFAULT_BUILDER_JSON to satisfy the booted() non-empty invariant.
*/
public static function makeUploaded(string $name, string $html): Template
{
$template = self::makeTemplate(
theme: 'default',
name: $name,
json: self::DEFAULT_BUILDER_JSON,
content: $html,
);
$template->source = self::SOURCE_UPLOADED;
$template->is_default = false;
return $template;
}
public function getThemeConfigData()
{
$theme = $this->theme ?: 'default';
return json_decode(file_get_contents(resource_path('themes/'.$theme.'/index.json')), true);
}
}