From 307067580cb8aab7fe96115fa585c3c12dd1ce4d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 5 Jul 2026 17:00:28 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20BackupRunner=20helper=20=E2=80=94=20uni?= =?UTF-8?q?fy=20backup=20run/status/notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every non-interactive backup trigger used a slightly different path, so final status and notification gating behaved inconsistently depending on what started the backup (this is why pre-update backups did not gate notifications the way "Backup Now" does). Add src/Service/BackupRunner.php as the single synchronous entry point. It delegates to BackupEngine (which already sends notifications gated by the profile's notify_on_success/notify_on_failure and applies retention — so there is exactly ONE notification layer), and normalises the result, including the real complete/warning/fail status the engine records but did not return. BackupRunner self-loads BackupEngine so callers require one file. Routed through BackupRunner: - plg_system runPreActionBackup() (preaction) + web-cron handler (webcron) - plg_content triggerAutoBackup() (pre-install) - plg_task scheduled task run - plg_console RunCommand (cli) The dashboard AJAX/stepped "Backup Now" flow is unchanged (it is the interactive equivalent). The installer progress popup is deferred — the installer request cannot drive the AJAX modal (TODO(#196) noted in code). php -l clean on all changed files. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i --- .../src/Service/BackupRunner.php | 109 ++++++++++++++++++ .../src/Command/RunCommand.php | 13 +-- .../src/Extension/MokoSuiteBackupContent.php | 13 +-- .../src/Extension/MokoSuiteBackup.php | 8 +- .../src/Extension/MokoSuiteBackupTask.php | 6 +- 5 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 source/packages/com_mokosuitebackup/src/Service/BackupRunner.php diff --git a/source/packages/com_mokosuitebackup/src/Service/BackupRunner.php b/source/packages/com_mokosuitebackup/src/Service/BackupRunner.php new file mode 100644 index 00000000..7ea36eba --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Service/BackupRunner.php @@ -0,0 +1,109 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Single synchronous entry point for running a backup. + * + * Every non-interactive trigger — the web-cron handler, the pre-install / + * pre-update / pre-uninstall hooks, the scheduled task plugin and the CLI + * command — runs through here, so the final status, notification gating and + * retention are applied identically regardless of what started the backup. + * (The dashboard's AJAX "Backup Now" is the interactive equivalent, driven by + * SteppedBackupEngine.) + * + * BackupEngine::run() already sends notifications (gated by the profile's + * notify_on_success / notify_on_failure) and applies retention, so this class + * DELEGATES rather than reimplementing that logic — there is exactly one + * notification layer. Its value is being the documented seam where origin-aware + * behaviour can live and normalising the result, including the real + * complete / warning / fail status that the engine records but does not return. + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Service; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; + +final class BackupRunner +{ + /** + * Run a full backup synchronously and return a normalised result. + * + * @param int $profileId Profile to back up. + * @param string $description Human-readable description stored on the record. + * @param string $origin Trigger origin (webcron|preaction|scheduled|cli|backend…). + * + * @return array{success:bool,status:string,message:string,record_id:int,warnings:array} + */ + public function run(int $profileId, string $description, string $origin = 'backend'): array + { + // TODO(#196): an interactive progress popup for installer-triggered runs + // (onExtensionBeforeUpdate) is a separate follow-up — the installer + // request cannot drive the AJAX/stepped progress modal. + try { + if (!class_exists(BackupEngine::class)) { + require_once __DIR__ . '/../Engine/BackupEngine.php'; + } + + $result = (new BackupEngine())->run($profileId, $description, $origin); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: backup run failed (' . $origin . '): ' . $e->getMessage()); + + return [ + 'success' => false, + 'status' => 'fail', + 'message' => 'Backup failed: ' . $e->getMessage(), + 'record_id' => 0, + 'warnings' => [], + ]; + } + + $success = (bool) ($result['success'] ?? false); + $recordId = (int) ($result['record_id'] ?? 0); + + return [ + 'success' => $success, + 'status' => $this->resolveStatus($success, $recordId), + 'message' => (string) ($result['message'] ?? ''), + 'record_id' => $recordId, + 'warnings' => $result['warnings'] ?? [], + ]; + } + + /** + * Read the status BackupEngine wrote to the record (complete/warning/fail) + * but does not return, so callers can distinguish a warning (archive created, + * remote upload failed) from a clean success. + */ + private function resolveStatus(bool $success, int $recordId): string + { + if (!$success) { + return 'fail'; + } + + if ($recordId <= 0) { + return 'complete'; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('status')) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $recordId); + $db->setQuery($query); + $status = (string) $db->loadResult(); + + return $status !== '' ? $status : 'complete'; + } catch (\Throwable $e) { + return 'complete'; + } + } +} diff --git a/source/packages/plg_console_mokosuitebackup/src/Command/RunCommand.php b/source/packages/plg_console_mokosuitebackup/src/Command/RunCommand.php index f75ea1d0..323c985b 100644 --- a/source/packages/plg_console_mokosuitebackup/src/Command/RunCommand.php +++ b/source/packages/plg_console_mokosuitebackup/src/Command/RunCommand.php @@ -13,7 +13,7 @@ namespace Joomla\Plugin\Console\MokoSuiteBackup\Command; defined('_JEXEC') or die; use Joomla\CMS\Factory; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; use Joomla\Console\Command\AbstractCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -40,20 +40,19 @@ class RunCommand extends AbstractCommand $io->title('MokoSuiteBackup — Run Backup'); $io->text('Profile ID: ' . $profileId); - $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/BackupEngine.php'; + $runnerFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Service/BackupRunner.php'; - if (!file_exists($engineFile)) { + if (!file_exists($runnerFile)) { $io->error('MokoSuiteBackup component not installed.'); return 1; } - if (!class_exists(BackupEngine::class)) { - require_once $engineFile; + if (!class_exists(BackupRunner::class)) { + require_once $runnerFile; } - $engine = new BackupEngine(); - $result = $engine->run($profileId, $desc ?: 'CLI backup', 'cli'); + $result = (new BackupRunner())->run($profileId, $desc ?: 'CLI backup', 'cli'); if ($result['success']) { $io->success($result['message']); diff --git a/source/packages/plg_content_mokosuitebackup/src/Extension/MokoSuiteBackupContent.php b/source/packages/plg_content_mokosuitebackup/src/Extension/MokoSuiteBackupContent.php index 4e0654bd..bd634172 100644 --- a/source/packages/plg_content_mokosuitebackup/src/Extension/MokoSuiteBackupContent.php +++ b/source/packages/plg_content_mokosuitebackup/src/Extension/MokoSuiteBackupContent.php @@ -14,7 +14,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; use Joomla\Event\Event; use Joomla\Event\SubscriberInterface; @@ -61,19 +61,18 @@ final class MokoSuiteBackupContent extends CMSPlugin implements SubscriberInterf $session->set('mokosuitebackup.content_last_autobackup', time()); - $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/BackupEngine.php'; + $runnerFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Service/BackupRunner.php'; - if (!file_exists($engineFile)) { + if (!file_exists($runnerFile)) { return; } - if (!class_exists(BackupEngine::class)) { - require_once $engineFile; + if (!class_exists(BackupRunner::class)) { + require_once $runnerFile; } try { - $engine = new BackupEngine(); - $engine->run($profileId, $description, 'backend'); + (new BackupRunner())->run($profileId, $description, 'backend'); } catch (\Throwable $e) { // Non-fatal — log and continue with the install/update Factory::getApplication()->enqueueMessage( diff --git a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php index 1e7406ad..30864e73 100644 --- a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php +++ b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php @@ -15,7 +15,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; use Joomla\Event\Event; use Joomla\Event\SubscriberInterface; @@ -91,8 +91,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface @ini_set('memory_limit', '512M'); try { - $engine = new BackupEngine(); - $result = $engine->run($profileId, 'Web cron backup', 'webcron'); + $result = (new BackupRunner())->run($profileId, 'Web cron backup', 'webcron'); $this->sendJsonResponse( $result['success'], @@ -390,8 +389,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface $profileId = (int) $params->get('default_profile', 1); try { - $engine = new BackupEngine(); - $result = $engine->run($profileId, $description, 'preaction'); + $result = (new BackupRunner())->run($profileId, $description, 'preaction'); if (!$result['success']) { Factory::getApplication()->enqueueMessage( diff --git a/source/packages/plg_task_mokosuitebackup/src/Extension/MokoSuiteBackupTask.php b/source/packages/plg_task_mokosuitebackup/src/Extension/MokoSuiteBackupTask.php index 25421484..04849c3a 100644 --- a/source/packages/plg_task_mokosuitebackup/src/Extension/MokoSuiteBackupTask.php +++ b/source/packages/plg_task_mokosuitebackup/src/Extension/MokoSuiteBackupTask.php @@ -72,7 +72,7 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface $profileId = (int) ($params->profile_id ?? 1); // Load the backup engine from the component - $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/BackupEngine.php'; + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Service/BackupRunner.php'; if (!file_exists($engineFile)) { $this->logTask('MokoSuiteBackup component not installed — cannot run backup.'); @@ -81,11 +81,11 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface } // The autoloader should handle this via namespace, but ensure class is available - if (!class_exists('\\Joomla\\Component\\MokoSuiteBackup\\Administrator\\Engine\\BackupEngine')) { + if (!class_exists('\\Joomla\\Component\\MokoSuiteBackup\\Administrator\\Service\\BackupRunner')) { require_once $engineFile; } - $engine = new \Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine(); + $engine = new \Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner(); $result = $engine->run($profileId, 'Scheduled task backup', 'scheduled'); if ($result['success']) {