0638c2cef6
Add `mokosuitebackup:snapshot` command with four actions: - create: --types=articles,categories,modules --description="text" - restore: --id=N --mode=replace|merge --types=articles - list: displays table of all snapshots - delete: --id=N removes file + DB record Closes #55
269 lines
6.9 KiB
PHP
269 lines
6.9 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage plg_console_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @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];
|
|
}
|
|
}
|