diff --git a/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php b/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php new file mode 100644 index 00000000..9dbf58af --- /dev/null +++ b/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php @@ -0,0 +1,268 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\Console\MokoSuiteBackup\Command; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputArgument; +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 SnapshotCommand extends AbstractCommand +{ + protected static $defaultName = 'mokosuitebackup:snapshot'; + + protected function configure(): void + { + $this->setDescription('Create, restore, list, or delete content snapshots'); + $this->addArgument('action', InputArgument::REQUIRED, 'Action to perform: create, restore, list, delete'); + $this->addOption('id', null, InputOption::VALUE_REQUIRED, 'Snapshot ID (required for restore and delete)'); + $this->addOption('types', null, InputOption::VALUE_REQUIRED, 'Comma-separated content types: articles,categories,modules', 'articles,categories,modules'); + $this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Snapshot description', ''); + $this->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Restore mode: replace or merge', 'replace'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $action = $input->getArgument('action'); + + $io->title('MokoSuiteBackup — Content Snapshot'); + + return match ($action) { + 'create' => $this->actionCreate($input, $io), + 'restore' => $this->actionRestore($input, $io), + 'list' => $this->actionList($io), + 'delete' => $this->actionDelete($input, $io), + default => $this->actionUnknown($action, $io), + }; + } + + private function actionCreate(InputInterface $input, SymfonyStyle $io): int + { + $types = array_map('trim', explode(',', $input->getOption('types'))); + $description = $input->getOption('description') ?: ''; + + $io->text('Types: ' . implode(', ', $types)); + + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotEngine.php'; + + if (!file_exists($engineFile)) { + $io->error('MokoSuiteBackup component not installed.'); + + return 1; + } + + if (!class_exists(SnapshotEngine::class)) { + require_once $engineFile; + } + + $engine = new SnapshotEngine(); + $result = $engine->create($types, $description ?: 'CLI snapshot'); + + if ($result['success']) { + $io->success($result['message']); + + if (isset($result['id'])) { + $io->text('Snapshot ID: ' . $result['id']); + } + + return 0; + } + + $io->error($result['message']); + + return 1; + } + + private function actionRestore(InputInterface $input, SymfonyStyle $io): int + { + $id = $input->getOption('id'); + + if (!$id) { + $io->error('The --id option is required for restore.'); + + return 1; + } + + $id = (int) $id; + $mode = $input->getOption('mode'); + + if (!\in_array($mode, ['replace', 'merge'], true)) { + $io->error('Invalid restore mode. Use "replace" or "merge".'); + + return 1; + } + + $typesRaw = $input->getOption('types'); + $contentTypes = ($typesRaw === 'articles,categories,modules') + ? [] + : array_map('trim', explode(',', $typesRaw)); + + $io->text('Snapshot ID: ' . $id); + $io->text('Mode: ' . $mode); + + if (!empty($contentTypes)) { + $io->text('Types: ' . implode(', ', $contentTypes)); + } else { + $io->text('Types: all from snapshot'); + } + + $io->warning('This will modify your site content.'); + + if (!$io->confirm('Are you sure you want to continue?', false)) { + $io->info('Restore cancelled.'); + + return 0; + } + + $engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php'; + + if (!file_exists($engineFile)) { + $io->error('SnapshotRestoreEngine not found. Is the component fully installed?'); + + return 1; + } + + if (!class_exists(SnapshotRestoreEngine::class)) { + require_once $engineFile; + } + + $engine = new SnapshotRestoreEngine(); + $result = $engine->restore($id, $mode, $contentTypes); + + if ($result['success']) { + $io->success($result['message']); + + return 0; + } + + $io->error($result['message']); + + return 1; + } + + private function actionList(SymfonyStyle $io): int + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('description'), + $db->quoteName('content_types'), + $db->quoteName('created'), + $db->quoteName('file_size'), + ]) + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->order($db->quoteName('id') . ' DESC'); + $db->setQuery($query); + $rows = $db->loadObjectList(); + + if (empty($rows)) { + $io->info('No snapshots found.'); + + return 0; + } + + $tableRows = []; + + foreach ($rows as $row) { + $size = isset($row->file_size) ? $this->formatBytes((int) $row->file_size) : '-'; + + $tableRows[] = [ + $row->id, + $row->description ?: '-', + $row->content_types ?: '-', + $row->created, + $size, + ]; + } + + $io->table( + ['ID', 'Description', 'Content Types', 'Created', 'Size'], + $tableRows + ); + + return 0; + } + + private function actionDelete(InputInterface $input, SymfonyStyle $io): int + { + $id = $input->getOption('id'); + + if (!$id) { + $io->error('The --id option is required for delete.'); + + return 1; + } + + $id = (int) $id; + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + $io->error('Snapshot not found: ' . $id); + + return 1; + } + + // Delete the snapshot file if it exists + if (!empty($record->file_path) && is_file($record->file_path)) { + if (!@unlink($record->file_path)) { + $io->warning('Could not delete snapshot file: ' . $record->file_path); + } else { + $io->text('Deleted file: ' . $record->file_path); + } + } + + // Delete the DB record + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $db->execute(); + + $io->success('Snapshot #' . $id . ' deleted.'); + + return 0; + } + + private function actionUnknown(string $action, SymfonyStyle $io): int + { + $io->error('Unknown action: ' . $action . '. Valid actions: create, restore, list, delete.'); + + return 1; + } + + private function formatBytes(int $bytes): string + { + if ($bytes === 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB']; + $i = (int) floor(log($bytes, 1024)); + + return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i]; + } +} diff --git a/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php b/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php index ce6eae4e..1556cafd 100644 --- a/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php +++ b/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php @@ -20,6 +20,7 @@ use Joomla\Plugin\Console\MokoSuiteBackup\Command\ListCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\ProfilesCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand; +use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand; final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface { @@ -41,5 +42,6 @@ final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterf $app->addCommand(new ProfilesCommand()); $app->addCommand(new RestoreCommand()); $app->addCommand(new CleanupCommand()); + $app->addCommand(new SnapshotCommand()); } }