feat: tar.gz archives, table checkbox excludes, user group notifications (#32, #33, #34)
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled

Archive formats (#32):
- ArchiverInterface abstraction with ZipArchiver and TarGzArchiver
- BackupEngine uses archiver factory based on profile archive_format
- tar.gz uses PharData (bundled with PHP, no extra extensions)
- RestoreEngine detects and extracts tar.gz via PharData
- AES-256 encryption skipped for non-ZIP formats with log warning

Exclude fields (#33):
- ExcludeListField: dynamic table with add/remove rows for dirs and files
- DatabaseTablesField: auto-populated checkbox list of all site tables
- Replaces textarea-based exclusion fields in profile form

User group notifications (#34):
- usergrouplist field added to profile notifications fieldset
- NotificationSender resolves group members to emails at send time
- Combined with manual email addresses, deduplicated
- SQL migration adds notify_user_groups column

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-04 20:40:07 -05:00
parent cf50551595
commit 75e72c248a
13 changed files with 500 additions and 27 deletions
+17 -9
View File
@@ -39,6 +39,7 @@
default="zip"
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
</field>
<field
name="compression_level"
@@ -114,30 +115,29 @@
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
<field
name="exclude_dirs"
type="textarea"
type="ExcludeList"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
rows="6"
filter="raw"
hint="tmp&#10;cache&#10;logs&#10;administrator/logs"
hint="tmp"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/>
<field
name="exclude_files"
type="textarea"
type="ExcludeList"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC"
rows="4"
filter="raw"
hint=".gitignore&#10;*.bak&#10;*.tmp"
hint="*.bak"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/>
<field
name="exclude_tables"
type="textarea"
type="DatabaseTables"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC"
rows="4"
filter="raw"
hint="#__session&#10;#__mail_queue"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/>
</fieldset>
@@ -176,6 +176,14 @@
maxlength="512"
hint="admin@example.com, backup@example.com"
/>
<field
name="notify_user_groups"
type="usergrouplist"
label="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS"
description="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
multiple="true"
layout="joomla.form.field.list-fancy-select"
/>
<field
name="notify_on_success"
type="radio"
@@ -235,6 +235,14 @@ COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
; Exclude fields
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
; User group notifications
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
; Dashboard warnings
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
@@ -53,3 +53,10 @@ COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1,
@@ -2,3 +2,6 @@
-- Fix: allow NULL defaults for manifest and log columns
ALTER TABLE `#__mokobackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL;
ALTER TABLE `#__mokobackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
-- Add user group notifications column to profiles
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
@@ -0,0 +1,41 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
interface ArchiverInterface
{
/**
* Open or create the archive at the given path.
*/
public function open(string $path): void;
/**
* Add a string as a file inside the archive.
*/
public function addFromString(string $localName, string $contents): void;
/**
* Add a file from disk into the archive.
*/
public function addFile(string $filePath, string $localName): void;
/**
* Finalize and close the archive.
*/
public function close(): void;
/**
* Return the file extension for this archive type (e.g. 'zip', 'tar.gz').
*/
public function getExtension(): string;
}
@@ -71,7 +71,10 @@ class BackupEngine
$now = date('Y-m-d H:i:s');
$tag = date('Ymd_His');
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip';
$archiveFormat = $profile->archive_format ?? 'zip';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.' . $archiveExt;
if (empty($description)) {
$description = $profile->title . ' — ' . $now;
@@ -105,12 +108,8 @@ class BackupEngine
$this->log('Backup started: ' . $description);
$archivePath = $this->backupDir . '/' . $archiveName;
// Create ZIP archive
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create archive: ' . $archivePath);
}
// Create archive
$archiver->open($archivePath);
$dbSize = 0;
$filesCount = 0;
@@ -121,7 +120,7 @@ class BackupEngine
$this->log('Starting database dump...');
$dumper = new DatabaseDumper($excludeTables);
$sqlDump = $dumper->dump();
$zip->addFromString('database.sql', $sqlDump);
$archiver->addFromString('database.sql', $sqlDump);
$dbSize = strlen($sqlDump);
$tablesCount = $dumper->getTablesCount();
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
@@ -157,7 +156,7 @@ class BackupEngine
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) {
$zip->addFile($fullPath, $relativePath);
$archiver->addFile($fullPath, $relativePath);
}
}
@@ -170,15 +169,19 @@ class BackupEngine
}
}
$zip->close();
$archiver->close();
// Step 1.5: Apply AES-256 encryption (if configured)
$encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
if ($archiveFormat !== 'zip') {
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
} else {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
}
}
// Record archive size and compute checksum (after encryption)
@@ -361,6 +364,18 @@ class BackupEngine
return true;
}
/**
* Create the appropriate archiver based on the archive format.
*/
private function createArchiver(string $format): ArchiverInterface
{
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
default => new ZipArchiver(),
};
}
/**
* Create the appropriate remote uploader based on the storage type.
*/
@@ -33,9 +33,13 @@ class NotificationSender
*/
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
{
$notifyEmail = trim($profile->notify_email ?? '');
$notifyEmail = trim($profile->notify_email ?? '');
$notifyUserGroups = $profile->notify_user_groups ?? '';
if (empty($notifyEmail)) {
// Resolve user group members to email addresses
$groupEmails = self::resolveUserGroupEmails($notifyUserGroups);
if (empty($notifyEmail) && empty($groupEmails)) {
return false;
}
@@ -54,9 +58,10 @@ class NotificationSender
$siteName = $config->get('sitename', 'Joomla Site');
$siteUrl = Uri::root();
// Parse recipient list (comma-separated)
// Parse recipient list (comma-separated) + user group emails
$recipients = array_map('trim', explode(',', $notifyEmail));
$recipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
$recipients = array_merge($recipients, $groupEmails);
$recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)));
if (empty($recipients)) {
return false;
@@ -133,4 +138,41 @@ class NotificationSender
return false;
}
}
/**
* Resolve user group IDs to email addresses of group members.
*
* @param string|array $groups Comma-separated group IDs or array
*
* @return array Email addresses
*/
private static function resolveUserGroupEmails(string|array $groups): array
{
if (empty($groups)) {
return [];
}
if (\is_string($groups)) {
$groups = array_filter(array_map('intval', explode(',', $groups)));
}
if (empty($groups)) {
return [];
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('u.email'))
->from($db->quoteName('#__users', 'u'))
->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id')
->where($db->quoteName('u.block') . ' = 0')
->whereIn($db->quoteName('ugm.group_id'), $groups);
$db->setQuery($query);
return $db->loadColumn() ?: [];
} catch (\Throwable $e) {
return [];
}
}
}
@@ -89,12 +89,15 @@ class RestoreEngine
// Step 1: Extract archive to staging
$this->log('Extracting archive: ' . basename($archivePath));
// Detect format: JPA or ZIP
// Detect format: JPA, tar.gz, or ZIP
if (JpaUnarchiver::isJpaFile($archivePath)) {
$this->log('Detected JPA format (Akeeba Backup archive)');
$jpa = new JpaUnarchiver($archivePath, $this->stagingDir);
$count = $jpa->extract();
$this->log('Extracted ' . $count . ' files from JPA');
} elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
$this->log('Detected tar.gz format');
$this->extractTarGz($archivePath);
} else {
$this->extractArchive($archivePath, $password);
}
@@ -200,6 +203,16 @@ class RestoreEngine
$zip->close();
}
/**
* Extract a tar.gz archive to the staging directory.
*/
private function extractTarGz(string $archivePath): void
{
$phar = new \PharData($archivePath);
$phar->extractTo($this->stagingDir, null, true);
$this->log('Extracted tar.gz archive');
}
/**
* Recursively delete a directory and all its contents.
*/
@@ -0,0 +1,63 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class TarGzArchiver implements ArchiverInterface
{
private \PharData $tar;
private string $tarPath;
public function open(string $path): void
{
// PharData creates .tar first, then we compress to .tar.gz
// Strip .gz to get the .tar path for initial creation
$this->tarPath = preg_replace('/\.gz$/', '', $path);
// Remove existing files to avoid "already exists" errors
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
if (is_file($path)) {
@unlink($path);
}
$this->tar = new \PharData($this->tarPath);
}
public function addFromString(string $localName, string $contents): void
{
$this->tar->addFromString($localName, $contents);
}
public function addFile(string $filePath, string $localName): void
{
$this->tar->addFile($filePath, $localName);
}
public function close(): void
{
// Compress the .tar to .tar.gz
$this->tar->compress(\Phar::GZ);
// Remove the uncompressed .tar
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
}
public function getExtension(): string
{
return 'tar.gz';
}
}
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class ZipArchiver implements ArchiverInterface
{
private \ZipArchive $zip;
public function open(string $path): void
{
$this->zip = new \ZipArchive();
if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create ZIP archive: ' . $path);
}
}
public function addFromString(string $localName, string $contents): void
{
$this->zip->addFromString($localName, $contents);
}
public function addFile(string $filePath, string $localName): void
{
$this->zip->addFile($filePath, $localName);
}
public function close(): void
{
$this->zip->close();
}
public function getExtension(): string
{
return 'zip';
}
}
@@ -0,0 +1,105 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class DatabaseTablesField extends FormField
{
protected $type = 'DatabaseTables';
protected function getInput(): string
{
$db = Factory::getDbo();
$tables = $db->getTableList();
$prefix = $db->getPrefix();
// Parse current exclusions (newline-separated)
$excluded = [];
if (!empty($this->value)) {
$excluded = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))));
}
// Normalize: replace literal #__ with actual prefix for comparison
$excludedNormalized = array_map(function ($t) use ($prefix) {
return str_replace('#__', $prefix, $t);
}, $excluded);
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$html = '<div class="mb-2">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
$html .= '<table class="table table-sm table-hover mb-0">';
$html .= '<thead class="sticky-top bg-white"><tr>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleAll" /></th>';
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($tables as $table) {
$isExcluded = \in_array($table, $excludedNormalized, true);
// Convert to #__ notation for storage
$storeValue = $table;
if (str_starts_with($table, $prefix)) {
$storeValue = '#__' . substr($table, \strlen($prefix));
}
$safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8');
$safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8');
$checked = $isExcluded ? ' checked' : '';
$html .= '<tr>';
$html .= '<td><input type="checkbox" class="' . $id . '_cb" value="' . $safeValue . '"' . $checked . ' /></td>';
$html .= '<td><code>' . $safeTable . '</code></td>';
$html .= '</tr>';
}
$html .= '</tbody></table></div></div>';
// Script to sync checkboxes to hidden field
$html .= <<<SCRIPT
<script>
(function() {
var hidden = document.getElementById('{$id}');
var cbs = document.querySelectorAll('.{$id}_cb');
var toggleAll = document.getElementById('{$id}_toggleAll');
function sync() {
var vals = [];
cbs.forEach(function(cb) { if (cb.checked) vals.push(cb.value); });
hidden.value = vals.join('\\n');
}
cbs.forEach(function(cb) { cb.addEventListener('change', sync); });
toggleAll.addEventListener('change', function() {
var state = this.checked;
cbs.forEach(function(cb) { cb.checked = state; });
sync();
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}
@@ -0,0 +1,120 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class ExcludeListField extends FormField
{
protected $type = 'ExcludeList';
protected function getInput(): string
{
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8');
// Parse current values (newline-separated)
$items = [];
if (!empty($this->value)) {
$items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))));
}
$html = '<div id="' . $id . '_wrapper">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
$html .= '<tbody>';
foreach ($items as $item) {
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
$html .= '<tr>';
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
$html .= '</div>';
$html .= <<<SCRIPT
<script>
(function() {
var wrapper = document.getElementById('{$id}_wrapper');
var hidden = document.getElementById('{$id}');
var tbody = document.querySelector('#{$id}_table tbody');
var addBtn = document.getElementById('{$id}_add');
var placeholder = '{$placeholder}';
function sync() {
var vals = [];
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
var v = inp.value.trim();
if (v) vals.push(v);
});
hidden.value = vals.join('\\n');
}
function addRow(value) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm {$id}_input';
inp.value = value || '';
inp.placeholder = placeholder;
inp.addEventListener('input', sync);
td1.appendChild(inp);
var td2 = document.createElement('td');
td2.className = 'w-1';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
var icon = document.createElement('span');
icon.className = 'icon-delete';
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
btn.addEventListener('click', function() { tr.remove(); sync(); });
td2.appendChild(btn);
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
inp.focus();
}
addBtn.addEventListener('click', function() { addRow(''); });
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
inp.addEventListener('input', sync);
});
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
btn.addEventListener('click', function() {
btn.closest('tr').remove();
sync();
});
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}