diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/src/packages/com_mokobackup/sql/install.mysql.sql
index fe7c580..c9c25d3 100644
--- a/src/packages/com_mokobackup/sql/install.mysql.sql
+++ b/src/packages/com_mokobackup/sql/install.mysql.sql
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups',
+ `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
@@ -30,8 +31,9 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
- `include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive',
+ `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,
@@ -63,8 +65,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` (
`remote_filename` VARCHAR(512) NOT NULL DEFAULT '',
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive',
`base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential',
- `manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison',
- `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log',
+ `manifest` LONGTEXT DEFAULT NULL COMMENT 'JSON file manifest for differential comparison',
+ `log` MEDIUMTEXT DEFAULT NULL COMMENT 'Step-by-step backup log',
PRIMARY KEY (`id`),
KEY `idx_profile` (`profile_id`),
KEY `idx_status` (`status`),
diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql
new file mode 100644
index 0000000..ef33f11
--- /dev/null
+++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.01.sql
@@ -0,0 +1 @@
+ALTER TABLE `#__mokobackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql
new file mode 100644
index 0000000..724a3f3
--- /dev/null
+++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.08.sql
@@ -0,0 +1,7 @@
+-- MokoJoomBackup 01.01.08
+-- 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`;
diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql
new file mode 100644
index 0000000..32222f4
--- /dev/null
+++ b/src/packages/com_mokobackup/sql/updates/mysql/01.01.09.sql
@@ -0,0 +1,3 @@
+-- MokoJoomBackup 01.01.09
+-- Add archive_name_format column with placeholder support
+ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
diff --git a/src/packages/com_mokobackup/src/Controller/AjaxController.php b/src/packages/com_mokobackup/src/Controller/AjaxController.php
index c904f53..7a01ebb 100644
--- a/src/packages/com_mokobackup/src/Controller/AjaxController.php
+++ b/src/packages/com_mokobackup/src/Controller/AjaxController.php
@@ -68,6 +68,115 @@ class AjaxController extends BaseController
$this->sendJson($result);
}
+ /**
+ * Browse server directories for the folder picker field.
+ * POST: task=ajax.browseDir&path=/some/path
+ */
+ public function browseDir(): void
+ {
+ if (!Session::checkToken('get') && !Session::checkToken('post')) {
+ $this->sendJson(['error' => true, 'message' => 'Invalid token']);
+
+ return;
+ }
+
+ $path = $this->input->getString('path', JPATH_ROOT);
+ $path = realpath($path) ?: $path;
+
+ if (!is_dir($path)) {
+ $this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]);
+
+ return;
+ }
+
+ // Security: only allow browsing within JPATH_ROOT or parent directories
+ // that could contain a backup folder (e.g., /home/user/backups)
+ $dirs = [];
+ $handle = @opendir($path);
+
+ if ($handle) {
+ while (($entry = readdir($handle)) !== false) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ $fullPath = $path . '/' . $entry;
+
+ if (is_dir($fullPath) && $entry[0] !== '.') {
+ $dirs[] = [
+ 'name' => $entry,
+ 'path' => $fullPath,
+ ];
+ }
+ }
+
+ closedir($handle);
+ }
+
+ usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
+
+ $parent = dirname($path);
+
+ $this->sendJson([
+ 'error' => false,
+ 'current' => $path,
+ 'parent' => ($parent !== $path) ? $parent : null,
+ 'dirs' => $dirs,
+ ]);
+ }
+
+ /**
+ * Load and return the log file contents for a backup record.
+ * POST: task=ajax.viewLog&id=123
+ */
+ public function viewLog(): void
+ {
+ if (!Session::checkToken('get') && !Session::checkToken('post')) {
+ $this->sendJson(['error' => true, 'message' => 'Invalid token']);
+
+ return;
+ }
+
+ $id = $this->input->getInt('id', 0);
+
+ if (!$id) {
+ $this->sendJson(['error' => true, 'message' => 'Missing record ID']);
+
+ return;
+ }
+
+ $db = \Joomla\CMS\Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['absolute_path', 'log']))
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('id') . ' = ' . $id);
+ $db->setQuery($query);
+ $record = $db->loadObject();
+
+ if (!$record) {
+ $this->sendJson(['error' => true, 'message' => 'Record not found']);
+
+ return;
+ }
+
+ // Try to load log from file alongside the archive
+ $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
+ $logContent = '';
+
+ if (is_file($logPath)) {
+ $logContent = file_get_contents($logPath);
+ } elseif (!empty($record->log)) {
+ // Fall back to database-stored log
+ $logContent = $record->log;
+ }
+
+ $this->sendJson([
+ 'error' => false,
+ 'log' => $logContent ?: '(no log available)',
+ 'source' => is_file($logPath) ? 'file' : 'database',
+ ]);
+ }
+
/**
* Send a JSON response and close the application.
*/
diff --git a/src/packages/com_mokobackup/src/Controller/BackupsController.php b/src/packages/com_mokobackup/src/Controller/BackupsController.php
index 0ad0490..c8a3f15 100644
--- a/src/packages/com_mokobackup/src/Controller/BackupsController.php
+++ b/src/packages/com_mokobackup/src/Controller/BackupsController.php
@@ -68,17 +68,28 @@ class BackupsController extends AdminController
return;
}
- $app = $this->app;
- $app->clearHeaders();
- $app->setHeader('Content-Type', 'application/zip');
- $app->setHeader('Content-Disposition', 'attachment; filename="' . basename($item->archivename) . '"');
- $app->setHeader('Content-Length', (string) filesize($item->absolute_path));
- $app->setHeader('Cache-Control', 'no-cache, must-revalidate');
- $app->sendHeaders();
+ // Flush any output buffers to prevent HTML mixing with binary data
+ while (@ob_end_clean()) {
+ // clear all buffers
+ }
+
+ $filename = basename($item->archivename);
+ $filesize = filesize($item->absolute_path);
+
+ // Detect content type from file extension
+ $contentType = str_ends_with($filename, '.tar.gz')
+ ? 'application/gzip'
+ : 'application/zip';
+
+ header('Content-Type: ' . $contentType);
+ header('Content-Disposition: attachment; filename="' . $filename . '"');
+ header('Content-Length: ' . $filesize);
+ header('Cache-Control: no-cache, must-revalidate');
+ header('Pragma: no-cache');
readfile($item->absolute_path);
- $app->close();
+ $this->app->close();
}
/**
diff --git a/src/packages/com_mokobackup/src/Controller/DisplayController.php b/src/packages/com_mokobackup/src/Controller/DisplayController.php
index 5e4ec11..5425324 100644
--- a/src/packages/com_mokobackup/src/Controller/DisplayController.php
+++ b/src/packages/com_mokobackup/src/Controller/DisplayController.php
@@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
- protected $default_view = 'backups';
+ protected $default_view = 'dashboard';
}
diff --git a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php
index cafa191..3edbe14 100644
--- a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php
+++ b/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php
@@ -246,7 +246,7 @@ class AkeebaImporter
's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '',
's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups',
'remote_keep_local' => 1,
- 'include_kickstart' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
+ 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
'published' => 1,
'ordering' => (int) $akProfile->id,
'created' => $now,
diff --git a/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php b/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php
new file mode 100644
index 0000000..8edfdfb
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Engine/ArchiverInterface.php
@@ -0,0 +1,41 @@
+
+ * @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;
+}
diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php
index 923c6d4..59254f4 100644
--- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php
+++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
+use Joomla\Event\Event;
class BackupEngine
{
@@ -59,18 +60,24 @@ class BackupEngine
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
- // Determine backup directory
- $this->backupDir = JPATH_ROOT . '/' . ($profile->backup_dir ?: 'administrator/components/com_mokobackup/backups');
+ // Resolve placeholders in directory and filename
+ $resolver = new PlaceholderResolver($profile);
+
+ $configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
+ $this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
if (!is_dir($this->backupDir)) {
mkdir($this->backupDir, 0755, true);
}
// Create backup record
- $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';
+ $now = date('Y-m-d H:i:s');
+ $tag = $resolver->getTag();
+ $archiveFormat = $profile->archive_format ?? 'zip';
+ $archiver = $this->createArchiver($archiveFormat);
+ $archiveExt = $archiver->getExtension();
+ $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
+ $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) {
$description = $profile->title . ' — ' . $now;
@@ -104,12 +111,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;
@@ -120,7 +123,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');
@@ -156,7 +159,7 @@ class BackupEngine
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) {
- $zip->addFile($fullPath, $relativePath);
+ $archiver->addFile($fullPath, $relativePath);
}
}
@@ -169,15 +172,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)
@@ -187,21 +194,21 @@ class BackupEngine
$this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
- // Step 2.5: Wrap with Kickstart restore script (if enabled)
- $includeKickstart = (bool) ($profile->include_kickstart ?? false);
+ // Step 2.5: Wrap with MokoRestore script (if enabled)
+ $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
- if ($includeKickstart) {
- $this->log('Wrapping with Kickstart restore script...');
- $kickstartName = str_replace('.zip', '-kickstart.zip', $archiveName);
- $kickstartPath = $this->backupDir . '/' . $kickstartName;
- Kickstart::wrap($archivePath, $kickstartPath);
+ if ($includeMokoRestore) {
+ $this->log('Wrapping with MokoRestore script...');
+ $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
+ $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
+ MokoRestore::wrap($archivePath, $mokoRestorePath);
// Replace the original archive with the wrapped one
@unlink($archivePath);
- rename($kickstartPath, $archivePath);
+ rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
- $this->log('Kickstart archive created: ' . $sizeHuman);
+ $this->log('MokoRestore archive created: ' . $sizeHuman);
}
$remoteFilename = '';
@@ -229,6 +236,11 @@ class BackupEngine
}
}
+ // Write log file alongside the archive
+ $logContent = implode("\n", $this->log);
+ $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
+ @file_put_contents($logPath, $logContent);
+
// Final record update
$update = (object) [
'id' => $recordId,
@@ -242,7 +254,7 @@ class BackupEngine
'remote_filename' => $remoteFilename,
'checksum' => $checksum,
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
- 'log' => implode("\n", $this->log),
+ 'log' => $logContent,
];
$db->updateObject('#__mokobackup_records', $update, 'id');
@@ -250,6 +262,9 @@ class BackupEngine
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
+ // Dispatch event for actionlog and other listeners
+ $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
+
return [
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
@@ -275,6 +290,9 @@ class BackupEngine
// Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
+ // Dispatch event for actionlog and other listeners
+ $this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
+
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
}
}
@@ -354,6 +372,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.
*/
@@ -445,6 +475,41 @@ class BackupEngine
));
}
+ /**
+ * Dispatch the onMokoBackupAfterRun event so plugins (actionlog, etc.) can react.
+ */
+ private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void
+ {
+ try {
+ $app = Factory::getApplication();
+
+ $event = new Event('onMokoBackupAfterRun', [
+ 'success' => $success,
+ 'record_id' => $recordId,
+ 'description' => $description,
+ 'profile_id' => $profileId,
+ 'origin' => $origin,
+ ]);
+
+ $app->getDispatcher()->dispatch('onMokoBackupAfterRun', $event);
+ } catch (\Throwable $e) {
+ // Never let a listener failure break the backup result
+ }
+ }
+
+ /**
+ * Resolve a backup directory path. Absolute paths are used as-is,
+ * relative paths are resolved from JPATH_ROOT.
+ */
+ private function resolveBackupDir(string $dir): string
+ {
+ if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
+ return rtrim($dir, '/\\');
+ }
+
+ return JPATH_ROOT . '/' . $dir;
+ }
+
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
diff --git a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php
index 3c81269..05661c5 100644
--- a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php
+++ b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php
@@ -16,15 +16,33 @@ use Joomla\CMS\Factory;
class DatabaseDumper
{
- private array $excludeTables;
+ /** @var array Tables to exclude entirely (both structure and data) */
+ private array $excludeBoth = [];
+
+ /** @var array Tables to exclude data only (structure is kept) */
+ private array $excludeDataOnly = [];
+
+ /** @var array Tables to exclude structure only (data is kept — unusual) */
+ private array $excludeStructureOnly = [];
+
private int $tablesCount = 0;
/**
- * @param array $excludeTables Table names to exclude (with #__ prefix)
+ * @param array $excludeTables Table names to exclude (with #__ prefix).
+ * Supports suffixes: :data-only, :structure-only.
+ * No suffix = exclude both (backward compatible).
*/
public function __construct(array $excludeTables = [])
{
- $this->excludeTables = $excludeTables;
+ foreach ($excludeTables as $entry) {
+ if (str_ends_with($entry, ':data-only')) {
+ $this->excludeDataOnly[] = substr($entry, 0, -10);
+ } elseif (str_ends_with($entry, ':structure-only')) {
+ $this->excludeStructureOnly[] = substr($entry, 0, -15);
+ } else {
+ $this->excludeBoth[] = $entry;
+ }
+ }
}
/**
@@ -62,29 +80,49 @@ class DatabaseDumper
// Check if excluded
$abstractName = '#__' . substr($table, strlen($prefix));
- if ($this->isExcluded($abstractName, $table)) {
+ if ($this->isExcludedBoth($abstractName, $table)) {
continue;
}
+ $skipData = $this->isExcludedDataOnly($abstractName, $table);
+ $skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
+
$this->tablesCount++;
- // Get CREATE TABLE statement
- $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
- $createRow = $db->loadRow();
+ $output[] = '-- --------------------------------------------------------';
+ $output[] = '-- Table: ' . $table;
- if (!$createRow || empty($createRow[1])) {
- continue;
+ if ($skipData) {
+ $output[] = '-- (data excluded)';
+ }
+
+ if ($skipStructure) {
+ $output[] = '-- (structure excluded)';
}
$output[] = '-- --------------------------------------------------------';
- $output[] = '-- Table: ' . $table;
- $output[] = '-- --------------------------------------------------------';
- $output[] = '';
- $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
- $output[] = $createRow[1] . ';';
$output[] = '';
- // Dump data in chunks
+ // Get CREATE TABLE statement (unless structure is excluded)
+ if (!$skipStructure) {
+ $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
+ $createRow = $db->loadRow();
+
+ if (!$createRow || empty($createRow[1])) {
+ continue;
+ }
+
+ $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
+ $output[] = $createRow[1] . ';';
+ $output[] = '';
+ }
+
+ // Dump data (unless data is excluded)
+ if ($skipData) {
+ $output[] = '';
+ continue;
+ }
+
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
$rowCount = (int) $db->loadResult();
@@ -135,11 +173,39 @@ class DatabaseDumper
}
/**
- * Check if a table is excluded.
+ * Check if a table is fully excluded (both data and structure).
*/
- private function isExcluded(string $abstractName, string $realName): bool
+ private function isExcludedBoth(string $abstractName, string $realName): bool
{
- foreach ($this->excludeTables as $pattern) {
+ foreach ($this->excludeBoth as $pattern) {
+ if ($pattern === $abstractName || $pattern === $realName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a table's data is excluded (structure only).
+ */
+ private function isExcludedDataOnly(string $abstractName, string $realName): bool
+ {
+ foreach ($this->excludeDataOnly as $pattern) {
+ if ($pattern === $abstractName || $pattern === $realName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a table's structure is excluded (data only).
+ */
+ private function isExcludedStructureOnly(string $abstractName, string $realName): bool
+ {
+ foreach ($this->excludeStructureOnly as $pattern) {
if ($pattern === $abstractName || $pattern === $realName) {
return true;
}
diff --git a/src/packages/com_mokobackup/src/Engine/Kickstart.php b/src/packages/com_mokobackup/src/Engine/MokoRestore.php
similarity index 98%
rename from src/packages/com_mokobackup/src/Engine/Kickstart.php
rename to src/packages/com_mokobackup/src/Engine/MokoRestore.php
index e230c10..38c2c0a 100644
--- a/src/packages/com_mokobackup/src/Engine/Kickstart.php
+++ b/src/packages/com_mokobackup/src/Engine/MokoRestore.php
@@ -9,7 +9,7 @@
*
* Standalone restore script generator.
*
- * When "Include Kickstart" is enabled on a profile, the backup archive
+ * When "Include MokoRestore" is enabled on a profile, the backup archive
* is wrapped:
*
* outer.zip
@@ -17,14 +17,14 @@
* └── site-backup.zip ← The actual site backup
*
* Upload outer.zip to a blank server, extract, open restore.php in a
- * browser, and it handles everything — just like Akeeba Kickstart.
+ * browser, and it handles everything — self-contained site restoration.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
-class Kickstart
+class MokoRestore
{
/**
* Wrap a backup archive with the standalone restore script.
@@ -39,7 +39,7 @@ class Kickstart
$zip = new \ZipArchive();
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
- throw new \RuntimeException('Cannot create kickstart archive: ' . $outputPath);
+ throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
}
// Add the standalone restore script
@@ -68,7 +68,7 @@ class Kickstart
return <<<'RESTORE_PHP'
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 [];
+ }
+ }
}
diff --git a/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php b/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php
new file mode 100644
index 0000000..712b219
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Engine/PlaceholderResolver.php
@@ -0,0 +1,122 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ *
+ * Resolves placeholders like [host], [date], [profile_name] in backup
+ * directory paths and archive filename formats.
+ */
+
+namespace Joomla\Component\MokoBackup\Administrator\Engine;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+
+class PlaceholderResolver
+{
+ /**
+ * Supported placeholders and their descriptions (for documentation).
+ */
+ public const PLACEHOLDERS = [
+ '[host]' => 'Server hostname',
+ '[date]' => 'Date as Ymd (e.g. 20260604)',
+ '[time]' => 'Time as His (e.g. 143025)',
+ '[datetime]' => 'Date and time as Ymd_His',
+ '[year]' => 'Four-digit year',
+ '[month]' => 'Two-digit month',
+ '[day]' => 'Two-digit day',
+ '[hour]' => 'Two-digit hour (24h)',
+ '[minute]' => 'Two-digit minute',
+ '[second]' => 'Two-digit second',
+ '[profile_id]' => 'Backup profile ID',
+ '[profile_name]' => 'Profile title (sanitized)',
+ '[site_name]' => 'Joomla site name (sanitized)',
+ '[type]' => 'Backup type (full, database, files, differential)',
+ '[random]' => 'Random 6-character hex string',
+ ];
+
+ private array $replacements;
+
+ /**
+ * @param object $profile The backup profile object
+ */
+ public function __construct(object $profile)
+ {
+ $now = new \DateTimeImmutable('now');
+ $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
+
+ $siteName = '';
+
+ try {
+ $siteName = Factory::getApplication()->get('sitename', '');
+ } catch (\Throwable $e) {
+ // Fallback: not critical
+ }
+
+ $this->replacements = [
+ '[host]' => $hostname,
+ '[date]' => $now->format('Ymd'),
+ '[time]' => $now->format('His'),
+ '[datetime]' => $now->format('Ymd_His'),
+ '[year]' => $now->format('Y'),
+ '[month]' => $now->format('m'),
+ '[day]' => $now->format('d'),
+ '[hour]' => $now->format('H'),
+ '[minute]' => $now->format('i'),
+ '[second]' => $now->format('s'),
+ '[profile_id]' => (string) ($profile->id ?? '0'),
+ '[profile_name]' => $this->sanitize($profile->title ?? 'default'),
+ '[site_name]' => $this->sanitize($siteName ?: 'joomla'),
+ '[type]' => $profile->backup_type ?? 'full',
+ '[random]' => bin2hex(random_bytes(3)),
+ ];
+ }
+
+ /**
+ * Replace all placeholders in a string.
+ *
+ * @param string $template String containing [placeholder] tokens
+ *
+ * @return string Resolved string
+ */
+ public function resolve(string $template): string
+ {
+ return str_replace(
+ array_keys($this->replacements),
+ array_values($this->replacements),
+ $template
+ );
+ }
+
+ /**
+ * Get the raw hostname value (for backward compatibility).
+ */
+ public function getHostname(): string
+ {
+ return $this->replacements['[host]'];
+ }
+
+ /**
+ * Get the datetime tag value (for backward compatibility).
+ */
+ public function getTag(): string
+ {
+ return $this->replacements['[datetime]'];
+ }
+
+ /**
+ * Sanitize a string for use in filenames/paths.
+ * Keeps alphanumerics, dots, hyphens, underscores. Replaces spaces with hyphens.
+ */
+ private function sanitize(string $value): string
+ {
+ $value = str_replace(' ', '-', trim($value));
+
+ return preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php
index eb33467..7099957 100644
--- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php
+++ b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php
@@ -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.
*/
diff --git a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php
index 02ef27a..7a7559a 100644
--- a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php
+++ b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php
@@ -57,20 +57,21 @@ class SteppedBackupEngine
$session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
$session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
$session->remoteStorage = $profile->remote_storage ?? 'none';
- $session->includeKickstart = (bool) ($profile->include_kickstart ?? false);
+ $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
- // Build archive path
- $backupDir = JPATH_ROOT . '/' . $session->backupDir;
+ // Resolve placeholders in directory and filename
+ $resolver = new PlaceholderResolver($profile);
+ $backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir));
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
- $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';
+ $now = date('Y-m-d H:i:s');
+ $tag = $resolver->getTag();
+ $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
+ $archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
$session->archiveName = $archiveName;
@@ -288,7 +289,7 @@ class SteppedBackupEngine
}
/**
- * Finalize phase: add database.sql to ZIP, apply kickstart wrapper.
+ * Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper.
*/
private function stepFinalize(SteppedSession $session): void
{
@@ -314,15 +315,15 @@ class SteppedBackupEngine
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
- // Kickstart wrapper
- if ($session->includeKickstart) {
- $session->log('Wrapping with Kickstart restore script...');
- $kickstartPath = $session->archivePath . '.kickstart.zip';
- Kickstart::wrap($session->archivePath, $kickstartPath);
+ // MokoRestore wrapper
+ if ($session->includeMokoRestore) {
+ $session->log('Wrapping with MokoRestore script...');
+ $mokoRestorePath = $session->archivePath . '.mokorestore.zip';
+ MokoRestore::wrap($session->archivePath, $mokoRestorePath);
@unlink($session->archivePath);
- rename($kickstartPath, $session->archivePath);
+ rename($mokoRestorePath, $session->archivePath);
$totalSize = filesize($session->archivePath);
- $session->log('Kickstart archive created');
+ $session->log('MokoRestore archive created');
}
// Update record
@@ -408,12 +409,18 @@ class SteppedBackupEngine
*/
private function completeRecord(SteppedSession $session): void
{
- $db = Factory::getDbo();
+ $db = Factory::getDbo();
+ $logContent = implode("\n", $session->log);
+
+ // Write log file alongside the archive
+ $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath);
+ @file_put_contents($logPath, $logContent);
+
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
- 'log' => implode("\n", $session->log),
+ 'log' => $logContent,
];
$db->updateObject('#__mokobackup_records', $update, 'id');
@@ -536,6 +543,19 @@ class SteppedBackupEngine
return $tables;
}
+ /**
+ * Resolve a backup directory path. Absolute paths are used as-is,
+ * relative paths are resolved from JPATH_ROOT.
+ */
+ private function resolveBackupDir(string $dir): string
+ {
+ if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
+ return rtrim($dir, '/\\');
+ }
+
+ return JPATH_ROOT . '/' . $dir;
+ }
+
private function parseNewlineList(string $text): array
{
if (empty($text)) {
diff --git a/src/packages/com_mokobackup/src/Engine/SteppedSession.php b/src/packages/com_mokobackup/src/Engine/SteppedSession.php
index b4f3002..76f000d 100644
--- a/src/packages/com_mokobackup/src/Engine/SteppedSession.php
+++ b/src/packages/com_mokobackup/src/Engine/SteppedSession.php
@@ -51,7 +51,7 @@ class SteppedSession
public array $excludeFiles = [];
public array $excludeTables = [];
public string $remoteStorage = 'none';
- public bool $includeKickstart = false;
+ public bool $includeMokoRestore = false;
public bool $remoteKeepLocal = true;
// Progress
diff --git a/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php
new file mode 100644
index 0000000..fdce0ce
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php
@@ -0,0 +1,63 @@
+
+ * @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';
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Engine/ZipArchiver.php b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php
new file mode 100644
index 0000000..e161035
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php
@@ -0,0 +1,47 @@
+
+ * @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';
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php
new file mode 100644
index 0000000..937202d
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php
@@ -0,0 +1,147 @@
+
+ * @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, with optional :data-only suffix)
+ $excludeData = [];
+ $excludeStructure = [];
+
+ if (!empty($this->value)) {
+ $lines = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))));
+
+ foreach ($lines as $line) {
+ // Normalize table name to real prefix for comparison
+ if (str_ends_with($line, ':data-only')) {
+ $tableName = str_replace('#__', $prefix, substr($line, 0, -10));
+ $excludeData[$tableName] = true;
+ } elseif (str_ends_with($line, ':structure-only')) {
+ $tableName = str_replace('#__', $prefix, substr($line, 0, -15));
+ $excludeStructure[$tableName] = true;
+ } else {
+ // No suffix = exclude both (backward compatible)
+ $tableName = str_replace('#__', $prefix, $line);
+ $excludeData[$tableName] = true;
+ $excludeStructure[$tableName] = true;
+ }
+ }
+ }
+
+ $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
+ $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
+
+ $html = '';
+ $html .= '
';
+ $html .= '
' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '
';
+ $html .= '
';
+
+ // Script to sync checkboxes to hidden field
+ $html .= <<
+SCRIPT;
+
+ return $html;
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Field/ExcludeListField.php b/src/packages/com_mokobackup/src/Field/ExcludeListField.php
new file mode 100644
index 0000000..483e68c
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Field/ExcludeListField.php
@@ -0,0 +1,120 @@
+
+ * @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 = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+
+ $html .= <<
+SCRIPT;
+
+ return $html;
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Field/FolderPickerField.php b/src/packages/com_mokobackup/src/Field/FolderPickerField.php
new file mode 100644
index 0000000..447725d
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Field/FolderPickerField.php
@@ -0,0 +1,170 @@
+
+ * @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 FolderPickerField extends FormField
+{
+ protected $type = 'FolderPicker';
+
+ protected function getInput(): string
+ {
+ $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
+ $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
+ $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
+ $jRoot = JPATH_ROOT;
+
+ // Resolve to absolute for display
+ $rawValue = $this->value ?: $this->default;
+
+ if ($rawValue && $rawValue[0] !== '/') {
+ $absPath = $jRoot . '/' . $rawValue;
+ } else {
+ $absPath = $rawValue;
+ }
+
+ $exists = is_dir($absPath);
+ $statusClass = $exists ? 'text-success' : 'text-danger';
+ $statusIcon = $exists ? 'icon-publish' : 'icon-unpublish';
+ $statusText = $exists
+ ? Text::_('COM_MOKOBACKUP_FOLDER_EXISTS')
+ : Text::_('COM_MOKOBACKUP_FOLDER_NOT_FOUND');
+ $absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
+
+ return <<
+
+
+
+
+
+
+ {$statusText}: {$absPathSafe}
+
+
+
+
+HTML;
+ }
+}
diff --git a/src/packages/com_mokobackup/src/Model/DashboardModel.php b/src/packages/com_mokobackup/src/Model/DashboardModel.php
new file mode 100644
index 0000000..48fcb79
--- /dev/null
+++ b/src/packages/com_mokobackup/src/Model/DashboardModel.php
@@ -0,0 +1,207 @@
+
+ * @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\Model;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\MVC\Model\BaseDatabaseModel;
+
+class DashboardModel extends BaseDatabaseModel
+{
+ /**
+ * Get the most recent completed backup record.
+ *
+ * @return object|null
+ */
+ public function getLastBackup(): ?object
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select('r.*, p.title AS profile_title')
+ ->from($db->quoteName('#__mokobackup_records', 'r'))
+ ->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
+ ->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
+ ->order($db->quoteName('r.backupend') . ' DESC');
+ $db->setQuery($query, 0, 1);
+
+ return $db->loadObject() ?: null;
+ }
+
+ /**
+ * Query com_scheduler for the next scheduled MokoBackup task.
+ *
+ * @return object|null Object with next_execution and title, or null
+ */
+ public function getNextScheduled(): ?object
+ {
+ $db = $this->getDatabase();
+
+ try {
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['t.next_execution', 't.title']))
+ ->from($db->quoteName('#__scheduler_tasks', 't'))
+ ->where($db->quoteName('t.type') . ' = ' . $db->quote('mokobackup.run_profile'))
+ ->where($db->quoteName('t.state') . ' = 1')
+ ->order($db->quoteName('t.next_execution') . ' ASC');
+ $db->setQuery($query, 0, 1);
+
+ return $db->loadObject() ?: null;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Get backup statistics.
+ *
+ * @return object Object with total_count, total_size, fail_count_7d
+ */
+ public function getStats(): object
+ {
+ $db = $this->getDatabase();
+
+ // Total completed backups and storage
+ $query = $db->getQuery(true)
+ ->select('COUNT(*) AS total_count')
+ ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
+ $db->setQuery($query);
+ $stats = $db->loadObject();
+
+ // Failures in last 7 days
+ $cutoff = date('Y-m-d H:i:s', strtotime('-7 days'));
+ $query = $db->getQuery(true)
+ ->select('COUNT(*) AS fail_count')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
+ ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff));
+ $db->setQuery($query);
+ $stats->fail_count_7d = (int) $db->loadResult();
+
+ return $stats;
+ }
+
+ /**
+ * Check system health for backup readiness.
+ *
+ * @return array Array of check results [{label, status, detail}]
+ */
+ public function getSystemHealth(): array
+ {
+ $checks = [];
+
+ // PHP version
+ $checks[] = (object) [
+ 'label' => 'PHP Version',
+ 'status' => version_compare(PHP_VERSION, '8.1.0', '>='),
+ 'detail' => PHP_VERSION,
+ ];
+
+ // ZipArchive extension
+ $checks[] = (object) [
+ 'label' => 'ZipArchive',
+ 'status' => extension_loaded('zip'),
+ 'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded',
+ ];
+
+ // AES-256 encryption support
+ $aesSupport = defined('ZipArchive::EM_AES_256');
+ $checks[] = (object) [
+ 'label' => 'AES-256 Encryption',
+ 'status' => $aesSupport,
+ 'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
+ ];
+
+ // Backup directory writable — check the default path
+ $defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
+ $backupDir = $defaultDir;
+
+ // If profiles use a custom directory, check that instead
+ $db2 = $this->getDatabase();
+ $qDir = $db2->getQuery(true)
+ ->select($db2->quoteName('backup_dir'))
+ ->from($db2->quoteName('#__mokobackup_profiles'))
+ ->where($db2->quoteName('published') . ' = 1')
+ ->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote(''))
+ ->where($db2->quoteName('backup_dir') . ' IS NOT NULL');
+ $db2->setQuery($qDir, 0, 1);
+ $profileDir = $db2->loadResult();
+
+ if ($profileDir) {
+ // Absolute paths used as-is, relative resolved from JPATH_ROOT
+ if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) {
+ $backupDir = rtrim($profileDir, '/\\');
+ } else {
+ $backupDir = JPATH_ROOT . '/' . $profileDir;
+ }
+ }
+
+ $writable = is_dir($backupDir) && is_writable($backupDir);
+ $checks[] = (object) [
+ 'label' => 'Backup Directory',
+ 'status' => $writable,
+ 'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir,
+ ];
+
+ // Disk space
+ $freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT);
+ $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0;
+ $checks[] = (object) [
+ 'label' => 'Free Disk Space',
+ 'status' => $freeGB >= 1.0,
+ 'detail' => $freeGB . ' GB free',
+ ];
+
+ return $checks;
+ }
+
+ /**
+ * Check if any profiles use the default (web-root) backup directory.
+ *
+ * @return bool
+ */
+ public function isUsingDefaultBackupDir(): bool
+ {
+ $db = $this->getDatabase();
+ $default = 'administrator/components/com_mokobackup/backups';
+
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__mokobackup_profiles'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
+ . ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
+ . ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
+ $db->setQuery($query);
+
+ return (int) $db->loadResult() > 0;
+ }
+
+ /**
+ * Get published backup profiles for the quick-action selector.
+ *
+ * @return array
+ */
+ public function getProfiles(): array
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['id', 'title', 'backup_type']))
+ ->from($db->quoteName('#__mokobackup_profiles'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->order($db->quoteName('ordering') . ' ASC');
+ $db->setQuery($query);
+
+ return $db->loadObjectList() ?: [];
+ }
+}
diff --git a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php
index 1dde363..f1a224c 100644
--- a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php
+++ b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
+use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
@@ -44,11 +45,62 @@ class HtmlView extends BaseHtmlView
$db->setQuery($query);
$this->profiles = $db->loadObjectList() ?: [];
+ $this->checkUpdateSite();
$this->addToolbar();
parent::display($tpl);
}
+ /**
+ * Show an info notice linking to the update site record so the user
+ * can configure their download key for automatic updates.
+ */
+ protected function checkUpdateSite(): void
+ {
+ try {
+ $db = Factory::getDbo();
+
+ // Find the update site ID linked to pkg_mokobackup
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('us.update_site_id'))
+ ->from($db->quoteName('#__update_sites', 'us'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__update_sites_extensions', 'use')
+ . ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__extensions', 'e')
+ . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
+ )
+ ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup'))
+ ->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
+ ->setLimit(1);
+
+ $db->setQuery($query);
+ $updateSiteId = (int) $db->loadResult();
+
+ if ($updateSiteId > 0) {
+ $editUrl = Route::_(
+ 'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId
+ );
+
+ Factory::getApplication()->enqueueMessage(
+ Text::sprintf('COM_MOKOBACKUP_UPDATE_SITE_NOTICE', $editUrl),
+ 'info'
+ );
+ } else {
+ Factory::getApplication()->enqueueMessage(
+ Text::_('COM_MOKOBACKUP_UPDATE_SITE_MISSING'),
+ 'warning'
+ );
+ }
+ } catch (\Throwable $e) {
+ // Non-critical — silently ignore
+ }
+ }
+
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database');
diff --git a/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php b/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php
new file mode 100644
index 0000000..ac24790
--- /dev/null
+++ b/src/packages/com_mokobackup/src/View/Dashboard/HtmlView.php
@@ -0,0 +1,50 @@
+
+ * @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\View\Dashboard;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
+use Joomla\CMS\Toolbar\ToolbarHelper;
+
+class HtmlView extends BaseHtmlView
+{
+ public ?object $lastBackup = null;
+ public ?object $nextScheduled = null;
+ public object $stats;
+ public array $systemHealth = [];
+ public array $profiles = [];
+ public bool $defaultDirWarning = false;
+
+ public function display($tpl = null): void
+ {
+ /** @var \Joomla\Component\MokoBackup\Administrator\Model\DashboardModel $model */
+ $model = $this->getModel();
+
+ $this->lastBackup = $model->getLastBackup();
+ $this->nextScheduled = $model->getNextScheduled();
+ $this->stats = $model->getStats();
+ $this->systemHealth = $model->getSystemHealth();
+ $this->profiles = $model->getProfiles();
+ $this->defaultDirWarning = $model->isUsingDefaultBackupDir();
+
+ $this->addToolbar();
+
+ parent::display($tpl);
+ }
+
+ protected function addToolbar(): void
+ {
+ ToolbarHelper::title(Text::_('COM_MOKOBACKUP_DASHBOARD_TITLE'), 'archive');
+ ToolbarHelper::preferences('com_mokobackup');
+ }
+}
diff --git a/src/packages/com_mokobackup/tmpl/backup/default.php b/src/packages/com_mokobackup/tmpl/backup/default.php
index 22976e9..2bf829b 100644
--- a/src/packages/com_mokobackup/tmpl/backup/default.php
+++ b/src/packages/com_mokobackup/tmpl/backup/default.php
@@ -12,7 +12,11 @@ defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Session\Session;
+$ajaxToken = Session::getFormToken();
+$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
?>
@@ -22,7 +26,17 @@ use Joomla\CMS\Language\Text;
|
- escape($this->item->status); ?> |
+
+ item->status) {
+ 'complete' => 'badge bg-success',
+ 'running' => 'badge bg-info',
+ 'fail' => 'badge bg-danger',
+ default => 'badge bg-secondary',
+ };
+ ?>
+ escape($this->item->status); ?>
+ |
|
@@ -34,7 +48,12 @@ use Joomla\CMS\Language\Text;
|
- item->total_size); ?> |
+
+ item->total_size); ?>
+ item->db_size > 0) : ?>
+ (: item->db_size); ?>)
+
+ |
|
@@ -46,7 +65,11 @@ use Joomla\CMS\Language\Text;
|
- escape($this->item->archivename); ?> |
+ escape($this->item->archivename); ?> |
+
+
+ |
+ escape($this->item->absolute_path); ?> |
|
@@ -56,7 +79,47 @@ use Joomla\CMS\Language\Text;
|
item->tables_count; ?> |
+ item->checksum)) : ?>
+
+ |
+ escape($this->item->checksum); ?> |
+
+
+ item->remote_filename)) : ?>
+
+ |
+ escape($this->item->remote_filename); ?> |
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokobackup/tmpl/backups/default.php b/src/packages/com_mokobackup/tmpl/backups/default.php
index 590f519..cba340d 100644
--- a/src/packages/com_mokobackup/tmpl/backups/default.php
+++ b/src/packages/com_mokobackup/tmpl/backups/default.php
@@ -99,7 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
id); ?>
- escape($item->description); ?>
+
+ escape($item->description); ?>
+
+ checksum)) : ?>
+ : checksum, 0, 16); ?>...
+
|
escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
@@ -130,13 +135,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
-
+ |
status === 'complete' && $item->filesexist) : ?>
+
|
id; ?>
@@ -274,5 +284,58 @@ $listDirn = $this->escape($this->state->get('list.direction'));
// Expose for toolbar button
window.mokobackupStart = startSteppedBackup;
+
+ // View Log modal handler
+ document.addEventListener('click', function(e) {
+ var btn = e.target.closest('.mb-view-log');
+ if (!btn) return;
+ e.preventDefault();
+ var recordId = btn.getAttribute('data-id');
+ var modal = document.getElementById('mb-log-modal');
+ var body = document.getElementById('mb-log-body');
+ body.textContent = 'Loading...';
+ modal.style.display = 'block';
+
+ var form = new URLSearchParams();
+ form.append('task', 'ajax.viewLog');
+ form.append('id', recordId);
+ form.append(TOKEN_NAME, '1');
+
+ fetch(AJAX_URL, {
+ method: 'POST',
+ body: form,
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.error) {
+ body.textContent = data.message || 'Error loading log';
+ } else {
+ body.textContent = data.log;
+ }
+ })
+ .catch(function(err) {
+ body.textContent = 'Error: ' + err.message;
+ });
+ });
+
+ document.addEventListener('click', function(e) {
+ if (e.target.id === 'mb-log-modal' || e.target.classList.contains('mb-log-close')) {
+ document.getElementById('mb-log-modal').style.display = 'none';
+ }
+ });
})();
+
+
+
diff --git a/src/packages/com_mokobackup/tmpl/dashboard/default.php b/src/packages/com_mokobackup/tmpl/dashboard/default.php
new file mode 100644
index 0000000..5f33f87
--- /dev/null
+++ b/src/packages/com_mokobackup/tmpl/dashboard/default.php
@@ -0,0 +1,278 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\HTML\HTMLHelper;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Session\Session;
+
+$ajaxToken = Session::getFormToken();
+$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
+?>
+defaultDirWarning) : ?>
+
+
+
+
+
+
+
+
+
+
+ lastBackup) : ?>
+
+ lastBackup->backupend, Text::_('DATE_FORMAT_LC4')); ?>
+
+
+ escape($this->lastBackup->profile_title); ?>
+ —
+ lastBackup->total_size); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ nextScheduled) : ?>
+
+ nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
+
+ escape($this->nextScheduled->title); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ stats->total_count; ?>
+
+
+
+
+
+
+
+
+
+
+ stats->total_size); ?>
+
+ stats->fail_count_7d > 0) : ?>
+
+ stats->fail_count_7d); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ profiles)) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ systemHealth as $check) : ?>
+
+ |
+ status) : ?>
+
+
+
+
+ |
+ escape($check->label); ?> |
+ escape($check->detail); ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+ Backup in Progress
+
+ Initializing...
+ Phase: init
+
+
+
+
diff --git a/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini
new file mode 100644
index 0000000..6997740
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.ini
@@ -0,0 +1,9 @@
+; MokoJoomBackup — Actionlog Plugin language file (en-GB)
+PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
+PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
+PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
diff --git a/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini
new file mode 100644
index 0000000..3e1c655
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/language/en-GB/plg_actionlog_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Actionlog Plugin system language file (en-GB)
+PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
+PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
diff --git a/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini
new file mode 100644
index 0000000..27cf1d6
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.ini
@@ -0,0 +1,9 @@
+; MokoJoomBackup — Actionlog Plugin language file (en-US)
+PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
+PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
+PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
+PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
diff --git a/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini
new file mode 100644
index 0000000..1737124
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/language/en-US/plg_actionlog_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Actionlog Plugin system language file (en-US)
+PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
+PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
diff --git a/src/packages/plg_actionlog_mokobackup/mokobackup.php b/src/packages/plg_actionlog_mokobackup/mokobackup.php
new file mode 100644
index 0000000..2a4226a
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/mokobackup.php
@@ -0,0 +1,11 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
diff --git a/src/packages/plg_actionlog_mokobackup/mokobackup.xml b/src/packages/plg_actionlog_mokobackup/mokobackup.xml
new file mode 100644
index 0000000..343dc1a
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/mokobackup.xml
@@ -0,0 +1,32 @@
+
+
+
+ plg_actionlog_mokobackup
+ 01.01.07-dev
+ 2026-06-04
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION
+
+ Joomla\Plugin\Actionlog\MokoBackup
+
+
+ mokobackup.php
+ services
+ src
+
+
+
+ language/en-GB/plg_actionlog_mokobackup.ini
+ language/en-GB/plg_actionlog_mokobackup.sys.ini
+
+
diff --git a/src/packages/plg_actionlog_mokobackup/services/provider.php b/src/packages/plg_actionlog_mokobackup/services/provider.php
new file mode 100644
index 0000000..b13a445
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/services/provider.php
@@ -0,0 +1,37 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Actionlog\MokoBackup\Extension\MokoBackupActionlog;
+
+return new class () implements ServiceProviderInterface {
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container) {
+ $plugin = new MokoBackupActionlog(
+ $container->get(DispatcherInterface::class),
+ (array) PluginHelper::getPlugin('actionlog', 'mokobackup')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php b/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php
new file mode 100644
index 0000000..2cb97e7
--- /dev/null
+++ b/src/packages/plg_actionlog_mokobackup/src/Extension/MokoBackupActionlog.php
@@ -0,0 +1,174 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Actionlog\MokoBackup\Extension;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Event\Model;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\Component\Actionlogs\Administrator\Helper\ActionlogsHelper;
+use Joomla\Event\Event;
+use Joomla\Event\SubscriberInterface;
+
+final class MokoBackupActionlog extends CMSPlugin implements SubscriberInterface
+{
+ protected $autoloadLanguage = true;
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onContentAfterSave' => 'onContentAfterSave',
+ 'onContentAfterDelete' => 'onContentAfterDelete',
+ 'onMokoBackupAfterRun' => 'onMokoBackupAfterRun',
+ ];
+ }
+
+ /**
+ * Log when a backup profile is saved (created or updated).
+ */
+ public function onContentAfterSave(Event $event): void
+ {
+ [$context, $table, $isNew] = array_values($event->getArguments());
+
+ if ($context !== 'com_mokobackup.profile') {
+ return;
+ }
+
+ $messageKey = $isNew
+ ? 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED'
+ : 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED';
+
+ $this->addLog(
+ [
+ $messageKey,
+ 'id' => $table->id,
+ 'title' => $table->title,
+ 'userid' => $this->getCurrentUserId(),
+ 'username' => $this->getCurrentUserName(),
+ ],
+ $messageKey,
+ 'com_mokobackup.profile',
+ $this->getCurrentUserId()
+ );
+ }
+
+ /**
+ * Log when a backup profile or record is deleted.
+ */
+ public function onContentAfterDelete(Event $event): void
+ {
+ [$context, $table] = array_values($event->getArguments());
+
+ if ($context === 'com_mokobackup.profile') {
+ $this->addLog(
+ [
+ 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
+ 'id' => $table->id,
+ 'title' => $table->title ?? '',
+ 'userid' => $this->getCurrentUserId(),
+ 'username' => $this->getCurrentUserName(),
+ ],
+ 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
+ 'com_mokobackup.profile',
+ $this->getCurrentUserId()
+ );
+ } elseif ($context === 'com_mokobackup.backup') {
+ $this->addLog(
+ [
+ 'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
+ 'id' => $table->id,
+ 'title' => $table->description ?? 'Backup #' . $table->id,
+ 'userid' => $this->getCurrentUserId(),
+ 'username' => $this->getCurrentUserName(),
+ ],
+ 'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
+ 'com_mokobackup.backup',
+ $this->getCurrentUserId()
+ );
+ }
+ }
+
+ /**
+ * Log when a backup completes or fails.
+ * This event should be dispatched from BackupEngine.
+ */
+ public function onMokoBackupAfterRun(Event $event): void
+ {
+ $args = $event->getArguments();
+
+ $success = $args['success'] ?? false;
+ $recordId = $args['record_id'] ?? 0;
+ $description = $args['description'] ?? '';
+ $profileId = $args['profile_id'] ?? 0;
+ $origin = $args['origin'] ?? 'backend';
+
+ $messageKey = $success
+ ? 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE'
+ : 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED';
+
+ $this->addLog(
+ [
+ $messageKey,
+ 'id' => $recordId,
+ 'title' => $description ?: 'Backup #' . $recordId,
+ 'profile_id' => $profileId,
+ 'origin' => $origin,
+ 'userid' => $this->getCurrentUserId(),
+ 'username' => $this->getCurrentUserName(),
+ ],
+ $messageKey,
+ 'com_mokobackup.backup',
+ $this->getCurrentUserId()
+ );
+ }
+
+ /**
+ * Write an action log entry.
+ */
+ private function addLog(array $message, string $messageLanguageKey, string $context, int $userId): void
+ {
+ $params = [
+ 'message_language_key' => $messageLanguageKey,
+ 'message' => json_encode($message),
+ 'date' => date('Y-m-d H:i:s'),
+ 'extension' => 'com_mokobackup',
+ 'user_id' => $userId,
+ 'ip_address' => ActionlogsHelper::getIp(),
+ 'item_id' => $message['id'] ?? 0,
+ ];
+
+ try {
+ $db = Factory::getDbo();
+ $db->insertObject('#__action_logs', (object) $params);
+ } catch (\Throwable $e) {
+ // Non-critical — don't break the operation
+ }
+ }
+
+ private function getCurrentUserId(): int
+ {
+ try {
+ return (int) Factory::getApplication()->getIdentity()->id;
+ } catch (\Throwable $e) {
+ return 0;
+ }
+ }
+
+ private function getCurrentUserName(): string
+ {
+ try {
+ return Factory::getApplication()->getIdentity()->username ?: 'system';
+ } catch (\Throwable $e) {
+ return 'system';
+ }
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini
new file mode 100644
index 0000000..4b87bca
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Console Plugin language file (en-GB)
+PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
+PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
diff --git a/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini
new file mode 100644
index 0000000..02fb8d8
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/language/en-GB/plg_console_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Console Plugin system language file (en-GB)
+PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
+PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
diff --git a/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini
new file mode 100644
index 0000000..9fa5c15
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Console Plugin language file (en-US)
+PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
+PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
diff --git a/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini
new file mode 100644
index 0000000..d22c08c
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/language/en-US/plg_console_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Console Plugin system language file (en-US)
+PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
+PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
diff --git a/src/packages/plg_console_mokobackup/mokobackup.php b/src/packages/plg_console_mokobackup/mokobackup.php
new file mode 100644
index 0000000..724a1bb
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/mokobackup.php
@@ -0,0 +1,11 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
diff --git a/src/packages/plg_console_mokobackup/mokobackup.xml b/src/packages/plg_console_mokobackup/mokobackup.xml
new file mode 100644
index 0000000..262f1c7
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/mokobackup.xml
@@ -0,0 +1,32 @@
+
+
+
+ plg_console_mokobackup
+ 01.01.07-dev
+ 2026-06-04
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ PLG_CONSOLE_MOKOBACKUP_DESCRIPTION
+
+ Joomla\Plugin\Console\MokoBackup
+
+
+ mokobackup.php
+ services
+ src
+
+
+
+ language/en-GB/plg_console_mokobackup.ini
+ language/en-GB/plg_console_mokobackup.sys.ini
+
+
diff --git a/src/packages/plg_console_mokobackup/services/provider.php b/src/packages/plg_console_mokobackup/services/provider.php
new file mode 100644
index 0000000..3bacb2f
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/services/provider.php
@@ -0,0 +1,37 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Console\MokoBackup\Extension\MokoBackupConsole;
+
+return new class () implements ServiceProviderInterface {
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container) {
+ $plugin = new MokoBackupConsole(
+ $container->get(DispatcherInterface::class),
+ (array) PluginHelper::getPlugin('console', 'mokobackup')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php b/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php
new file mode 100644
index 0000000..1a8509a
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Command/CleanupCommand.php
@@ -0,0 +1,125 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Command;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\Console\Command\AbstractCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class CleanupCommand extends AbstractCommand
+{
+ protected static $defaultName = 'mokobackup:cleanup';
+
+ protected function configure(): void
+ {
+ $this->setDescription('Clean up old backup records and archive files');
+ $this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Max age in days', '30');
+ $this->addOption('max-count', null, InputOption::VALUE_REQUIRED, 'Max number of backups to keep', '10');
+ $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without deleting');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $maxAge = (int) $input->getOption('max-age');
+ $maxCount = (int) $input->getOption('max-count');
+ $dryRun = $input->getOption('dry-run');
+
+ $io->title('MokoJoomBackup — Cleanup');
+
+ if ($dryRun) {
+ $io->note('Dry run — no files will be deleted.');
+ }
+
+ $db = Factory::getDbo();
+ $deleted = 0;
+
+ // Delete by age
+ $cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
+ $query = $db->getQuery(true)
+ ->select('id, absolute_path, description, backupstart')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
+ $db->setQuery($query);
+ $expired = $db->loadObjectList();
+
+ foreach ($expired as $record) {
+ $io->text('Expired: #' . $record->id . ' — ' . $record->backupstart . ' — ' . ($record->description ?: 'no description'));
+
+ if (!$dryRun) {
+ if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
+ @unlink($record->absolute_path);
+ }
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('id') . ' = ' . (int) $record->id)
+ );
+ $db->execute();
+ }
+
+ $deleted++;
+ }
+
+ // Enforce max count
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
+ $db->setQuery($query);
+ $totalCount = (int) $db->loadResult();
+
+ if ($totalCount > $maxCount) {
+ $excess = $totalCount - $maxCount;
+ $query = $db->getQuery(true)
+ ->select('id, absolute_path, description, backupstart')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
+ ->order($db->quoteName('backupstart') . ' ASC');
+ $db->setQuery($query, 0, $excess);
+ $oldest = $db->loadObjectList();
+
+ foreach ($oldest as $record) {
+ $io->text('Over limit: #' . $record->id . ' — ' . $record->backupstart);
+
+ if (!$dryRun) {
+ if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
+ @unlink($record->absolute_path);
+ }
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('id') . ' = ' . (int) $record->id)
+ );
+ $db->execute();
+ }
+
+ $deleted++;
+ }
+ }
+
+ if ($deleted === 0) {
+ $io->success('No backups to clean up.');
+ } else {
+ $io->success(($dryRun ? 'Would delete ' : 'Deleted ') . $deleted . ' backup record(s).');
+ }
+
+ return 0;
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/src/Command/ListCommand.php b/src/packages/plg_console_mokobackup/src/Command/ListCommand.php
new file mode 100644
index 0000000..9586339
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Command/ListCommand.php
@@ -0,0 +1,87 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Command;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\Console\Command\AbstractCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class ListCommand extends AbstractCommand
+{
+ protected static $defaultName = 'mokobackup:list';
+
+ protected function configure(): void
+ {
+ $this->setDescription('List backup records');
+ $this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Number of records to show', '20');
+ $this->addOption('status', 's', InputOption::VALUE_OPTIONAL, 'Filter by status (complete, fail, running)');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $limit = (int) $input->getOption('limit');
+ $status = $input->getOption('status');
+
+ $io->title('MokoJoomBackup — Backup Records');
+
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('r.id, r.description, r.status, r.origin, r.backup_type, r.total_size, r.backupstart, r.backupend')
+ ->select($db->quoteName('p.title', 'profile_title'))
+ ->from($db->quoteName('#__mokobackup_records', 'r'))
+ ->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
+ ->order($db->quoteName('r.backupstart') . ' DESC');
+
+ if ($status) {
+ $query->where($db->quoteName('r.status') . ' = ' . $db->quote($status));
+ }
+
+ $db->setQuery($query, 0, $limit);
+ $records = $db->loadObjectList();
+
+ if (empty($records)) {
+ $io->info('No backup records found.');
+
+ return 0;
+ }
+
+ $rows = [];
+
+ foreach ($records as $record) {
+ $size = $record->total_size > 0
+ ? round($record->total_size / 1048576, 2) . ' MB'
+ : '—';
+
+ $rows[] = [
+ $record->id,
+ $record->profile_title ?: '—',
+ $record->status,
+ $record->backup_type,
+ $size,
+ $record->origin,
+ $record->backupstart,
+ ];
+ }
+
+ $io->table(
+ ['ID', 'Profile', 'Status', 'Type', 'Size', 'Origin', 'Started'],
+ $rows
+ );
+
+ return 0;
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php b/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php
new file mode 100644
index 0000000..8f4b21c
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Command/ProfilesCommand.php
@@ -0,0 +1,68 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Command;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\Console\Command\AbstractCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class ProfilesCommand extends AbstractCommand
+{
+ protected static $defaultName = 'mokobackup:profiles';
+
+ protected function configure(): void
+ {
+ $this->setDescription('List available backup profiles');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $io->title('MokoJoomBackup — Backup Profiles');
+
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('id, title, backup_type, published, ordering')
+ ->from($db->quoteName('#__mokobackup_profiles'))
+ ->order($db->quoteName('ordering') . ' ASC');
+ $db->setQuery($query);
+ $profiles = $db->loadObjectList();
+
+ if (empty($profiles)) {
+ $io->info('No backup profiles found.');
+
+ return 0;
+ }
+
+ $rows = [];
+
+ foreach ($profiles as $profile) {
+ $rows[] = [
+ $profile->id,
+ $profile->title,
+ $profile->backup_type,
+ $profile->published ? 'Yes' : 'No',
+ ];
+ }
+
+ $io->table(
+ ['ID', 'Title', 'Type', 'Published'],
+ $rows
+ );
+
+ return 0;
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php b/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php
new file mode 100644
index 0000000..e5f9082
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Command/RestoreCommand.php
@@ -0,0 +1,101 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Command;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine;
+use Joomla\Console\Command\AbstractCommand;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class RestoreCommand extends AbstractCommand
+{
+ protected static $defaultName = 'mokobackup:restore';
+
+ protected function configure(): void
+ {
+ $this->setDescription('Restore a backup by record ID');
+ $this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $recordId = (int) $input->getArgument('id');
+
+ $io->title('MokoJoomBackup — Restore Backup');
+
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokobackup_records'))
+ ->where($db->quoteName('id') . ' = ' . $recordId);
+ $db->setQuery($query);
+ $record = $db->loadObject();
+
+ if (!$record) {
+ $io->error('Backup record not found: ' . $recordId);
+
+ return 1;
+ }
+
+ if ($record->status !== 'complete') {
+ $io->error('Cannot restore — backup status is: ' . $record->status);
+
+ return 1;
+ }
+
+ if (empty($record->absolute_path) || !is_file($record->absolute_path)) {
+ $io->error('Backup archive not found: ' . ($record->absolute_path ?: 'no path'));
+
+ return 1;
+ }
+
+ $io->warning('This will overwrite the current site files and/or database.');
+ $io->text('Archive: ' . $record->absolute_path);
+ $io->text('Type: ' . $record->backup_type);
+
+ if (!$io->confirm('Are you sure you want to continue?', false)) {
+ $io->info('Restore cancelled.');
+
+ return 0;
+ }
+
+ $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/RestoreEngine.php';
+
+ if (!file_exists($engineFile)) {
+ $io->error('RestoreEngine not found. Is the component fully installed?');
+
+ return 1;
+ }
+
+ if (!class_exists(RestoreEngine::class)) {
+ require_once $engineFile;
+ }
+
+ $engine = new RestoreEngine();
+ $result = $engine->restore($record->absolute_path, $record->backup_type);
+
+ if ($result['success']) {
+ $io->success($result['message']);
+
+ return 0;
+ }
+
+ $io->error($result['message']);
+
+ return 1;
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/src/Command/RunCommand.php b/src/packages/plg_console_mokobackup/src/Command/RunCommand.php
new file mode 100644
index 0000000..d187737
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Command/RunCommand.php
@@ -0,0 +1,68 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Command;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
+use Joomla\Console\Command\AbstractCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+class RunCommand extends AbstractCommand
+{
+ protected static $defaultName = 'mokobackup:run';
+
+ protected function configure(): void
+ {
+ $this->setDescription('Run a backup using a specified profile');
+ $this->addOption('profile', 'p', InputOption::VALUE_REQUIRED, 'Profile ID to use', '1');
+ $this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Backup description', '');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $profileId = (int) $input->getOption('profile');
+ $desc = $input->getOption('description') ?: '';
+
+ $io->title('MokoJoomBackup — Run Backup');
+ $io->text('Profile ID: ' . $profileId);
+
+ $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
+
+ if (!file_exists($engineFile)) {
+ $io->error('MokoJoomBackup component not installed.');
+
+ return 1;
+ }
+
+ if (!class_exists(BackupEngine::class)) {
+ require_once $engineFile;
+ }
+
+ $engine = new BackupEngine();
+ $result = $engine->run($profileId, $desc ?: 'CLI backup', 'cli');
+
+ if ($result['success']) {
+ $io->success($result['message']);
+
+ return 0;
+ }
+
+ $io->error($result['message']);
+
+ return 1;
+ }
+}
diff --git a/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php b/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php
new file mode 100644
index 0000000..fca96ac
--- /dev/null
+++ b/src/packages/plg_console_mokobackup/src/Extension/MokoBackupConsole.php
@@ -0,0 +1,45 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Console\MokoBackup\Extension;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\Event\Event;
+use Joomla\Event\SubscriberInterface;
+use Joomla\Plugin\Console\MokoBackup\Command\CleanupCommand;
+use Joomla\Plugin\Console\MokoBackup\Command\ListCommand;
+use Joomla\Plugin\Console\MokoBackup\Command\ProfilesCommand;
+use Joomla\Plugin\Console\MokoBackup\Command\RestoreCommand;
+use Joomla\Plugin\Console\MokoBackup\Command\RunCommand;
+
+final class MokoBackupConsole extends CMSPlugin implements SubscriberInterface
+{
+ protected $autoloadLanguage = true;
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ \Joomla\Application\ApplicationEvents::BEFORE_EXECUTE => 'registerCommands',
+ ];
+ }
+
+ public function registerCommands(Event $event): void
+ {
+ $app = $this->getApplication();
+
+ $app->addCommand(new RunCommand());
+ $app->addCommand(new ListCommand());
+ $app->addCommand(new ProfilesCommand());
+ $app->addCommand(new RestoreCommand());
+ $app->addCommand(new CleanupCommand());
+ }
+}
diff --git a/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini
new file mode 100644
index 0000000..5f23262
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.ini
@@ -0,0 +1,9 @@
+; MokoJoomBackup — Content Plugin language file (en-GB)
+PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
+PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
+PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
+PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
diff --git a/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini
new file mode 100644
index 0000000..3d79871
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/language/en-GB/plg_content_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Content Plugin system language file (en-GB)
+PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
+PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
diff --git a/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini
new file mode 100644
index 0000000..1bac9a8
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.ini
@@ -0,0 +1,9 @@
+; MokoJoomBackup — Content Plugin language file (en-US)
+PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
+PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
+PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
+PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
+PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
diff --git a/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini
new file mode 100644
index 0000000..7a612b3
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/language/en-US/plg_content_mokobackup.sys.ini
@@ -0,0 +1,3 @@
+; MokoJoomBackup — Content Plugin system language file (en-US)
+PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
+PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
diff --git a/src/packages/plg_content_mokobackup/mokobackup.php b/src/packages/plg_content_mokobackup/mokobackup.php
new file mode 100644
index 0000000..2dd15e4
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/mokobackup.php
@@ -0,0 +1,11 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
diff --git a/src/packages/plg_content_mokobackup/mokobackup.xml b/src/packages/plg_content_mokobackup/mokobackup.xml
new file mode 100644
index 0000000..8548bda
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/mokobackup.xml
@@ -0,0 +1,71 @@
+
+
+
+ plg_content_mokobackup
+ 01.01.07-dev
+ 2026-06-04
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ PLG_CONTENT_MOKOBACKUP_DESCRIPTION
+
+ Joomla\Plugin\Content\MokoBackup
+
+
+ mokobackup.php
+ services
+ src
+
+
+
+ language/en-GB/plg_content_mokobackup.ini
+ language/en-GB/plg_content_mokobackup.sys.ini
+
+
+
+
+
+
+
+
diff --git a/src/packages/plg_content_mokobackup/services/provider.php b/src/packages/plg_content_mokobackup/services/provider.php
new file mode 100644
index 0000000..4635162
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/services/provider.php
@@ -0,0 +1,37 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Joomla\Plugin\Content\MokoBackup\Extension\MokoBackupContent;
+
+return new class () implements ServiceProviderInterface {
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container) {
+ $plugin = new MokoBackupContent(
+ $container->get(DispatcherInterface::class),
+ (array) PluginHelper::getPlugin('content', 'mokobackup')
+ );
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php b/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php
new file mode 100644
index 0000000..b27d119
--- /dev/null
+++ b/src/packages/plg_content_mokobackup/src/Extension/MokoBackupContent.php
@@ -0,0 +1,95 @@
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+namespace Joomla\Plugin\Content\MokoBackup\Extension;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
+use Joomla\Event\Event;
+use Joomla\Event\SubscriberInterface;
+
+final class MokoBackupContent extends CMSPlugin implements SubscriberInterface
+{
+ protected $autoloadLanguage = true;
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onExtensionBeforeInstall' => 'onExtensionBeforeInstall',
+ 'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate',
+ ];
+ }
+
+ /**
+ * Trigger a backup before a new extension is installed.
+ */
+ public function onExtensionBeforeInstall(Event $event): void
+ {
+ if (!(int) $this->params->get('backup_before_install', 0)) {
+ return;
+ }
+
+ $this->triggerAutoBackup('Pre-install backup');
+ }
+
+ /**
+ * Trigger a backup before an extension is updated.
+ */
+ public function onExtensionBeforeUpdate(Event $event): void
+ {
+ if (!(int) $this->params->get('backup_before_update', 1)) {
+ return;
+ }
+
+ $this->triggerAutoBackup('Pre-update backup');
+ }
+
+ /**
+ * Run a backup using the configured profile.
+ */
+ private function triggerAutoBackup(string $description): void
+ {
+ $profileId = (int) $this->params->get('profile_id', 1);
+
+ // Throttle: only one auto-backup per hour via session
+ $session = Factory::getSession();
+ $lastRun = $session->get('mokobackup.content_last_autobackup', 0);
+
+ if (time() - $lastRun < 3600) {
+ return;
+ }
+
+ $session->set('mokobackup.content_last_autobackup', time());
+
+ $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
+
+ if (!file_exists($engineFile)) {
+ return;
+ }
+
+ if (!class_exists(BackupEngine::class)) {
+ require_once $engineFile;
+ }
+
+ try {
+ $engine = new BackupEngine();
+ $engine->run($profileId, $description, 'backend');
+ } catch (\Throwable $e) {
+ // Non-fatal — log and continue with the install/update
+ Factory::getApplication()->enqueueMessage(
+ 'MokoJoomBackup auto-backup failed: ' . $e->getMessage(),
+ 'warning'
+ );
+ }
+ }
+}
diff --git a/src/packages/plg_quickicon_mokobackup/mokobackup.xml b/src/packages/plg_quickicon_mokobackup/mokobackup.xml
index b2ff32a..4341a06 100644
--- a/src/packages/plg_quickicon_mokobackup/mokobackup.xml
+++ b/src/packages/plg_quickicon_mokobackup/mokobackup.xml
@@ -1,7 +1,7 @@
plg_quickicon_mokobackup
- 01.00.00
+ 01.01.07-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
diff --git a/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php
index 5d0bd9a..c72cda5 100644
--- a/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php
+++ b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php
@@ -15,6 +15,7 @@ namespace Joomla\Plugin\Quickicon\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
@@ -96,7 +97,7 @@ final class MokoBackupQuickicon extends CMSPlugin implements SubscriberInterface
'link' => 'index.php?option=com_mokobackup&view=backups',
'image' => $warning ? 'icon-warning' : 'icon-database',
'icon' => $warning ? 'icon-warning' : 'icon-database',
- 'text' => $text,
+ 'text' => Text::_($text),
'linkadd' => $subtitle ? ' ' . htmlspecialchars($subtitle) . '' : '',
'id' => 'plg_quickicon_mokobackup',
'group' => 'MOD_QUICKICON_MAINTENANCE',
diff --git a/src/packages/plg_system_mokobackup/mokobackup.xml b/src/packages/plg_system_mokobackup/mokobackup.xml
index f13d733..899f088 100644
--- a/src/packages/plg_system_mokobackup/mokobackup.xml
+++ b/src/packages/plg_system_mokobackup/mokobackup.xml
@@ -8,7 +8,7 @@
-->
plg_system_mokobackup
- 01.00.00
+ 01.01.07-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
diff --git a/src/packages/plg_task_mokobackup/mokobackup.xml b/src/packages/plg_task_mokobackup/mokobackup.xml
index 92b274e..cfb1ffc 100644
--- a/src/packages/plg_task_mokobackup/mokobackup.xml
+++ b/src/packages/plg_task_mokobackup/mokobackup.xml
@@ -8,7 +8,7 @@
-->
plg_task_mokobackup
- 01.00.00
+ 01.01.07-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
diff --git a/src/packages/plg_webservices_mokobackup/mokobackup.xml b/src/packages/plg_webservices_mokobackup/mokobackup.xml
index 5d4f103..5ae3520 100644
--- a/src/packages/plg_webservices_mokobackup/mokobackup.xml
+++ b/src/packages/plg_webservices_mokobackup/mokobackup.xml
@@ -8,7 +8,7 @@
-->
plg_webservices_mokobackup
- 01.00.00
+ 01.01.07-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
diff --git a/src/pkg_mokobackup.xml b/src/pkg_mokobackup.xml
index 334846c..86dd028 100644
--- a/src/pkg_mokobackup.xml
+++ b/src/pkg_mokobackup.xml
@@ -8,7 +8,7 @@
Package - MokoJoomBackup
mokobackup
- 01.00.00
+ 01.01.07-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
@@ -25,6 +25,9 @@
plg_task_mokobackup.zip
plg_quickicon_mokobackup.zip
plg_webservices_mokobackup.zip
+ plg_console_mokobackup.zip
+ plg_content_mokobackup.zip
+ plg_actionlog_mokobackup.zip
@@ -32,6 +35,8 @@
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/main/updates.xml
+ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/updates.xml
+
+ true
diff --git a/src/script.php b/src/script.php
index ed0db68..d970bcd 100644
--- a/src/script.php
+++ b/src/script.php
@@ -12,6 +12,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
class Pkg_MokoBackupInstallerScript
{
@@ -107,6 +108,39 @@ class Pkg_MokoBackupInstallerScript
$db->setQuery($query);
$db->execute();
+ // Enable the console plugin automatically
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('enabled') . ' = 1')
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('console'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Enable the content plugin automatically
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('enabled') . ' = 1')
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('content'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Enable the actionlog plugin automatically
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('enabled') . ' = 1')
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('actionlog'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
+
+ $db->setQuery($query);
+ $db->execute();
+
// Create default backup directory
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
@@ -118,5 +152,54 @@ class Pkg_MokoBackupInstallerScript
file_put_contents($backupDir . '/index.html', '');
}
}
+
+ // Show update site link after install or update
+ $this->showUpdateSiteNotice();
+ }
+
+ /**
+ * Show an info message linking directly to the update site record
+ * so the user can configure their download key.
+ *
+ * @return void
+ */
+ private function showUpdateSiteNotice(): void
+ {
+ try {
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('us.update_site_id'))
+ ->from($db->quoteName('#__update_sites', 'us'))
+ ->join(
+ 'INNER',
+ $db->quoteName('#__update_sites_extensions', 'use')
+ . ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
+ )
+ ->join(
+ 'INNER',
+ $db->quoteName('#__extensions', 'e')
+ . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
+ )
+ ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup'))
+ ->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
+ ->setLimit(1);
+
+ $db->setQuery($query);
+ $updateSiteId = (int) $db->loadResult();
+
+ if ($updateSiteId > 0) {
+ $editUrl = Route::_(
+ 'index.php?option=com_installer&view=updatesites&filter[search]=mokobackup'
+ );
+
+ Factory::getApplication()->enqueueMessage(
+ Text::sprintf('PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE', $editUrl),
+ 'info'
+ );
+ }
+ } catch (\Throwable $e) {
+ // Non-critical — silently ignore
+ }
}
}
diff --git a/updates.xml b/updates.xml
deleted file mode 100644
index 5ba8cf1..0000000
--- a/updates.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- Package - MokoJoomBackup
- Full-site backup and restore for Joomla
- mokobackup
- package
- 01.00.00
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/tag/v01.00.00
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/download/v01.00.00/pkg_mokobackup-01.00.00.zip
-
-
- 8.1.0
-
-
|