diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fc724..82ec744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added +- Run Backup button on profiles list and edit views with backup count badges (#100, #101) +- Snapshot detail view with tabbed browser for articles, categories, and modules (#104) +- "Do not navigate away" warning in backup and restore progress modals (#108) +- Joomla Action Logs integration for restore, snapshot, and snapshot restore events (#110) +- 8 comprehensive testing issues created (#111-#118) +- Manual purge feature issue (#119) + ## [01.36.00] --- 2026-06-23 ## [01.36.00] --- 2026-06-23 diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 1f2f08d..8160212 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -78,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile" COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile" +; Profile actions +COM_MOKOJOOMBACKUP_RUN_BACKUP="Run" +COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now" +COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups" +COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups" + ; Table headings COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description" COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile" @@ -415,6 +421,20 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address" COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add" +; Snapshot browse / detail view +COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot" +COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles" +COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories" +COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules" +COM_MOKOJOOMBACKUP_HEADING_STATE="State" +COM_MOKOJOOMBACKUP_HEADING_POSITION="Position" +COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type" +COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level" +COM_MOKOJOOMBACKUP_LOADING="Loading..." +COM_MOKOJOOMBACKUP_SELECT_ALL="Select All" +COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected" +COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore." + ; Errors COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore." diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini index f6a8606..2e0b135 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOJOOMBACKUP_RUN_BACKUP="Run" +COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now" +COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups" +COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups" COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your Update Site with your download key." COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server." COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your Update Site to receive automatic updates." diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 2af280e..71f33ae 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -622,28 +622,67 @@ class AjaxController extends BaseController $data = json_decode($json, true); - if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) { - $this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']); return; } + $tables = $data['tables'] ?? []; + + // Articles $articles = []; - foreach ($data['tables']['#__content'] as $row) { - $articles[] = [ - 'id' => (int) ($row['id'] ?? 0), - 'title' => $row['title'] ?? '', - 'catid' => (int) ($row['catid'] ?? 0), - 'state' => (int) ($row['state'] ?? 0), - 'created' => $row['created'] ?? '', - ]; + if (!empty($tables['#__content'])) { + foreach ($tables['#__content'] as $row) { + $articles[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $row['title'] ?? '', + 'catid' => (int) ($row['catid'] ?? 0), + 'state' => (int) ($row['state'] ?? 0), + 'created' => $row['created'] ?? '', + ]; + } + } + + // Categories + $categories = []; + + if (!empty($tables['#__categories'])) { + foreach ($tables['#__categories'] as $row) { + $categories[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $row['title'] ?? '', + 'extension' => $row['extension'] ?? '', + 'published' => (int) ($row['published'] ?? 0), + 'level' => (int) ($row['level'] ?? 0), + ]; + } + } + + // Modules + $modules = []; + + if (!empty($tables['#__modules'])) { + foreach ($tables['#__modules'] as $row) { + $modules[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $row['title'] ?? '', + 'module' => $row['module'] ?? '', + 'position' => $row['position'] ?? '', + 'published' => (int) ($row['published'] ?? 0), + ]; + } } $this->sendJson([ - 'error' => false, - 'articles' => $articles, - 'total' => count($articles), + 'error' => false, + 'articles' => $articles, + 'categories' => $categories, + 'modules' => $modules, + 'total_articles' => \count($articles), + 'total_categories' => \count($categories), + 'total_modules' => \count($modules), ]); } diff --git a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php index 29a3d0c..55202d0 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php @@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class RestoreEngine { @@ -166,6 +167,9 @@ class RestoreEngine error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage()); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRestore(true, $recordId); + return [ 'success' => true, 'message' => 'Restore complete from: ' . basename($archivePath), @@ -185,6 +189,9 @@ class RestoreEngine $this->recursiveDelete($this->stagingDir); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRestore(false, $recordId); + return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), @@ -285,6 +292,26 @@ class RestoreEngine @rmdir($dir); } + /** + * Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react. + */ + private function dispatchAfterRestore(bool $success, int $recordId): void + { + try { + $app = Factory::getApplication(); + + $event = new Event('onMokoSuiteBackupAfterRestore', [ + 'success' => $success, + 'record_id' => $recordId, + ]); + + $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event); + } catch (\Throwable $e) { + // Never let a listener failure break the restore result, but log it + error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage()); + } + } + private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index cb63a2a..b61211c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -17,6 +17,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; +use Joomla\Event\Event; class SnapshotEngine { @@ -214,6 +215,9 @@ class SnapshotEngine error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage()); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes)); + return [ 'success' => true, 'message' => sprintf( @@ -227,6 +231,9 @@ class SnapshotEngine } catch (\Exception $e) { $this->log('FATAL: ' . $e->getMessage()); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshot(false, 0, $contentTypes); + return [ 'success' => false, 'message' => 'Snapshot failed: ' . $e->getMessage(), @@ -327,6 +334,27 @@ class SnapshotEngine return $db->loadAssocList() ?: []; } + /** + * Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react. + */ + private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void + { + try { + $app = Factory::getApplication(); + + $event = new Event('onMokoSuiteBackupAfterSnapshot', [ + 'success' => $success, + 'snapshot_id' => $snapshotId, + 'content_types' => $contentTypes, + ]); + + $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event); + } catch (\Throwable $e) { + // Never let a listener failure break the snapshot result, but log it + error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage()); + } + } + private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 7bb9014..0bf3c30 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class SnapshotRestoreEngine { @@ -170,6 +171,9 @@ class SnapshotRestoreEngine error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode); + return [ 'success' => true, 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)), @@ -185,6 +189,9 @@ class SnapshotRestoreEngine $this->log('FATAL: ' . $e->getMessage()); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode); + return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), @@ -537,6 +544,9 @@ class SnapshotRestoreEngine error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage()); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective'); + return [ 'success' => true, 'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows), @@ -553,6 +563,9 @@ class SnapshotRestoreEngine $this->log('FATAL: ' . $e->getMessage()); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective'); + return [ 'success' => false, 'message' => 'Selective restore failed: ' . $e->getMessage(), @@ -561,6 +574,27 @@ class SnapshotRestoreEngine } } + /** + * Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react. + */ + private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void + { + try { + $app = Factory::getApplication(); + + $event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [ + 'success' => $success, + 'snapshot_id' => $snapshotId, + 'mode' => $mode, + ]); + + $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event); + } catch (\Throwable $e) { + // Never let a listener failure break the restore result, but log it + error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage()); + } + } + private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Model/ProfilesModel.php b/source/packages/com_mokosuitebackup/src/Model/ProfilesModel.php index 44ad336..5cc8246 100644 --- a/source/packages/com_mokosuitebackup/src/Model/ProfilesModel.php +++ b/source/packages/com_mokosuitebackup/src/Model/ProfilesModel.php @@ -40,6 +40,13 @@ class ProfilesModel extends ListModel $query->select('a.*') ->from($db->quoteName('#__mokosuitebackup_profiles', 'a')); + // Subquery: count of backup records per profile + $subQuery = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records', 'r')) + ->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id')); + $query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count')); + $published = $this->getState('filter.published'); if (is_numeric($published)) { diff --git a/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php index 3d8eb33..6145618 100644 --- a/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php @@ -15,6 +15,9 @@ 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\Session\Session; +use Joomla\CMS\Toolbar\Toolbar; use Joomla\CMS\Toolbar\ToolbarHelper; class HtmlView extends BaseHtmlView @@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView ToolbarHelper::save('profile.save'); } + if (!$isNew) { + $toolbar = Toolbar::getInstance(); + $profileId = (int) $this->item->id; + + // "Run Backup Now" button — links to backup start with CSRF token + if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) { + $runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1'); + $toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW') + ->url($runUrl) + ->icon('icon-play') + ->buttonClass('btn btn-success'); + } + + // "View Backups" link button + $backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $profileId); + $toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS') + ->url($backupsUrl) + ->icon('icon-database') + ->buttonClass('btn btn-info'); + } + ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); } } diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index 91a857d..ee6ee8e 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -191,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));