* @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]; } }