feat: selective article restore from snapshot (#58)
Browse articles inside a snapshot and restore individual items: - SnapshotRestoreEngine::restoreSelectedArticles() merges by ID - AjaxController::browseSnapshot() returns article list as JSON - SnapshotsController::restoreSelected() handles selective restore - Browse modal with checkboxes + Restore Selected button Closes #58
This commit is contained in:
@@ -44,6 +44,22 @@ COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
||||
; Backup detail view
|
||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||
; Backup comparison
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
|
||||
@@ -77,6 +77,22 @@ COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
||||
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||
; Backup comparison
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
|
||||
@@ -377,6 +377,391 @@ class AjaxController extends BaseController
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse archive contents without extracting.
|
||||
* POST: task=ajax.browseArchive&id=123
|
||||
*/
|
||||
public function browseArchive(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete' || !$record->filesexist) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$archivePath = $record->absolute_path;
|
||||
|
||||
if (!is_file($archivePath)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$maxEntries = 500;
|
||||
|
||||
try {
|
||||
$files = [];
|
||||
$totalFiles = 0;
|
||||
$totalSize = 0;
|
||||
$truncated = false;
|
||||
|
||||
$lower = strtolower($archivePath);
|
||||
|
||||
if (substr($lower, -4) === '.zip') {
|
||||
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
|
||||
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||
} else {
|
||||
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'files' => $files,
|
||||
'total_files' => $totalFiles,
|
||||
'total_size' => $totalSize,
|
||||
'truncated' => $truncated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse a ZIP archive and return file entries.
|
||||
*
|
||||
* @param string $path Absolute path to the ZIP file
|
||||
* @param int $maxEntries Maximum entries to return
|
||||
* @param int &$totalFiles Total number of files (by reference)
|
||||
* @param int &$totalSize Total uncompressed size (by reference)
|
||||
* @param bool &$truncated Whether results were truncated (by reference)
|
||||
*
|
||||
* @return array List of file entry arrays
|
||||
*/
|
||||
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
|
||||
throw new \RuntimeException('Cannot open ZIP archive');
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$totalFiles = $zip->numFiles;
|
||||
|
||||
for ($i = 0; $i < $totalFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
|
||||
if ($stat === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalSize += $stat['size'];
|
||||
|
||||
if (\count($files) < $maxEntries) {
|
||||
$files[] = [
|
||||
'name' => $stat['name'],
|
||||
'size' => $stat['size'],
|
||||
'compressed_size' => $stat['comp_size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = $totalFiles > $maxEntries;
|
||||
$zip->close();
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse a tar.gz archive and return file entries.
|
||||
*
|
||||
* @param string $path Absolute path to the tar.gz file
|
||||
* @param int $maxEntries Maximum entries to return
|
||||
* @param int &$totalFiles Total number of files (by reference)
|
||||
* @param int &$totalSize Total uncompressed size (by reference)
|
||||
* @param bool &$truncated Whether results were truncated (by reference)
|
||||
*
|
||||
* @return array List of file entry arrays
|
||||
*/
|
||||
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||
{
|
||||
$phar = new \PharData($path);
|
||||
$files = [];
|
||||
|
||||
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
||||
$totalFiles++;
|
||||
$entrySize = $entry->getSize();
|
||||
$totalSize += $entrySize;
|
||||
|
||||
if (\count($files) < $maxEntries) {
|
||||
// Strip the phar:// prefix and archive path to get relative name
|
||||
$fullPath = str_replace('\\', '/', $entry->getPathname());
|
||||
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
|
||||
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
|
||||
?: $fullPath;
|
||||
|
||||
$files[] = [
|
||||
'name' => $relativeName,
|
||||
'size' => $entrySize,
|
||||
'compressed_size' => $entrySize,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = $totalFiles > $maxEntries;
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse articles inside a snapshot — returns JSON list for the browse modal.
|
||||
* POST: task=ajax.browseSnapshot&id=123
|
||||
*/
|
||||
public function browseSnapshot(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'total' => count($articles),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two backup records side-by-side.
|
||||
* POST: task=ajax.compareBackups&id1=123&id2=456
|
||||
*/
|
||||
public function compareBackups(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id1 = $this->input->getInt('id1', 0);
|
||||
$id2 = $this->input->getInt('id2', 0);
|
||||
|
||||
if (!$id1 || !$id2) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($id1 === $id2) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'r.id', 'r.description', 'r.status', 'r.backup_type',
|
||||
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
|
||||
'r.backupstart', 'r.backupend',
|
||||
];
|
||||
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName($fields))
|
||||
->select($db->quoteName('p.title', 'profile_title'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
|
||||
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
|
||||
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList('id');
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($rows[$id1])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($rows[$id2])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$b1 = $rows[$id1];
|
||||
$b2 = $rows[$id2];
|
||||
|
||||
// Calculate durations in seconds
|
||||
$duration1 = 0;
|
||||
$duration2 = 0;
|
||||
|
||||
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
|
||||
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
|
||||
}
|
||||
|
||||
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
|
||||
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
|
||||
}
|
||||
|
||||
$formatRecord = function ($row) {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'description' => $row->description,
|
||||
'status' => $row->status,
|
||||
'backup_type' => $row->backup_type,
|
||||
'total_size' => (int) $row->total_size,
|
||||
'db_size' => (int) $row->db_size,
|
||||
'files_count' => (int) $row->files_count,
|
||||
'tables_count' => (int) $row->tables_count,
|
||||
'backupstart' => $row->backupstart,
|
||||
'backupend' => $row->backupend,
|
||||
'profile_title' => $row->profile_title ?? '',
|
||||
];
|
||||
};
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'backup1' => $formatRecord($b1),
|
||||
'backup2' => $formatRecord($b2),
|
||||
'delta' => [
|
||||
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
|
||||
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
|
||||
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
|
||||
'duration_diff_seconds' => $duration2 - $duration1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
||||
|
||||
@@ -106,6 +107,151 @@ class SnapshotsController extends AdminController
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse articles inside a snapshot — returns JSON for AJAX modal.
|
||||
*/
|
||||
public function browse(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'total' => count($articles),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore selected articles from a snapshot.
|
||||
*/
|
||||
public function restoreSelected(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
$articleIds = $this->input->get('article_ids', [], 'array');
|
||||
|
||||
if (!$id) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($articleIds)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SnapshotRestoreEngine();
|
||||
$result = $engine->restoreSelectedArticles($id, $articleIds);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
private function sendJson(array $data, int $status = 200): void
|
||||
{
|
||||
$app = $this->app;
|
||||
$app->setHeader('status', $status);
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
$app->sendHeaders();
|
||||
|
||||
echo json_encode($data);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete snapshot records and their data files.
|
||||
*/
|
||||
|
||||
@@ -386,6 +386,181 @@ class SnapshotRestoreEngine
|
||||
return array_unique($tables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore only selected articles (and their related rows) from a snapshot.
|
||||
*
|
||||
* Uses merge/upsert mode: updates existing rows by ID, inserts missing ones.
|
||||
*
|
||||
* @param int $snapshotId Snapshot record ID
|
||||
* @param array $articleIds Article IDs to restore
|
||||
*
|
||||
* @return array{success: bool, message: string, restored?: int, log?: string}
|
||||
*/
|
||||
public function restoreSelectedArticles(int $snapshotId, array $articleIds): array
|
||||
{
|
||||
if (empty($articleIds)) {
|
||||
return ['success' => false, 'message' => 'No article IDs provided'];
|
||||
}
|
||||
|
||||
$articleIds = array_map('intval', $articleIds);
|
||||
$articleIds = array_filter($articleIds, fn($id) => $id > 0);
|
||||
|
||||
if (empty($articleIds)) {
|
||||
return ['success' => false, 'message' => 'No valid article IDs provided'];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load snapshot record
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $snapshotId);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
|
||||
}
|
||||
|
||||
$this->log('Loading snapshot file: ' . basename($record->data_file));
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
return ['success' => false, 'message' => 'Cannot read snapshot file'];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
|
||||
}
|
||||
|
||||
if (!is_array($data) || empty($data['tables'])) {
|
||||
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
|
||||
}
|
||||
|
||||
$contentTable = $data['tables']['#__content'] ?? [];
|
||||
|
||||
if (empty($contentTable)) {
|
||||
return ['success' => false, 'message' => 'Snapshot does not contain articles'];
|
||||
}
|
||||
|
||||
// Filter #__content rows to only selected article IDs
|
||||
$selectedRows = array_filter($contentTable, fn($row) => in_array((int) ($row['id'] ?? 0), $articleIds, true));
|
||||
|
||||
if (empty($selectedRows)) {
|
||||
return ['success' => false, 'message' => 'None of the selected article IDs exist in this snapshot'];
|
||||
}
|
||||
|
||||
$foundIds = array_map(fn($row) => (int) $row['id'], $selectedRows);
|
||||
$this->log('Restoring ' . count($selectedRows) . ' articles: IDs ' . implode(', ', $foundIds));
|
||||
|
||||
// Filter workflow_associations for selected articles
|
||||
$workflowRows = [];
|
||||
|
||||
if (!empty($data['tables']['#__workflow_associations'])) {
|
||||
$workflowRows = array_filter(
|
||||
$data['tables']['#__workflow_associations'],
|
||||
fn($row) => in_array((int) ($row['item_id'] ?? 0), $foundIds, true)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter tag_map entries for selected articles
|
||||
$tagMapRows = [];
|
||||
|
||||
if (!empty($data['tables']['#__contentitem_tag_map'])) {
|
||||
$tagMapRows = array_filter(
|
||||
$data['tables']['#__contentitem_tag_map'],
|
||||
fn($row) => in_array((int) ($row['content_item_id'] ?? 0), $foundIds, true)
|
||||
&& str_starts_with($row['type_alias'] ?? '', 'com_content.')
|
||||
);
|
||||
}
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$totalRows = 0;
|
||||
|
||||
try {
|
||||
$db->transactionStart();
|
||||
|
||||
// Restore articles using merge/upsert
|
||||
$realTable = str_replace('#__', $prefix, '#__content');
|
||||
$rowCount = $this->restoreMerge($db, $realTable, '#__content', array_values($selectedRows));
|
||||
$totalRows += $rowCount;
|
||||
$this->log(' #__content: ' . $rowCount . ' rows restored');
|
||||
|
||||
// Restore workflow associations
|
||||
if (!empty($workflowRows)) {
|
||||
$realTable = str_replace('#__', $prefix, '#__workflow_associations');
|
||||
$rowCount = $this->restoreMerge($db, $realTable, '#__workflow_associations', array_values($workflowRows));
|
||||
$totalRows += $rowCount;
|
||||
$this->log(' #__workflow_associations: ' . $rowCount . ' rows restored');
|
||||
}
|
||||
|
||||
// Restore tag map entries
|
||||
if (!empty($tagMapRows)) {
|
||||
$realTable = str_replace('#__', $prefix, '#__contentitem_tag_map');
|
||||
$rowCount = $this->restoreMerge($db, $realTable, '#__contentitem_tag_map', array_values($tagMapRows));
|
||||
$totalRows += $rowCount;
|
||||
$this->log(' #__contentitem_tag_map: ' . $rowCount . ' rows restored');
|
||||
}
|
||||
|
||||
$db->transactionCommit();
|
||||
|
||||
$this->log('Selective restore complete: ' . $totalRows . ' total rows');
|
||||
|
||||
// Send notification
|
||||
try {
|
||||
$profile = NotificationSender::getDefaultProfile();
|
||||
|
||||
if ($profile) {
|
||||
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
|
||||
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
|
||||
|
||||
NotificationSender::sendRestoreNotification($profile, 'snapshot_selective_restore', [
|
||||
'mode' => 'selective',
|
||||
'article_ids' => $foundIds,
|
||||
'row_count' => $totalRows,
|
||||
'user' => $userName . ' (ID: ' . $userIdVal . ')',
|
||||
], implode("\n", $this->log));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||
'restored' => count($selectedRows),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$db->transactionRollback();
|
||||
$this->log('Transaction rolled back');
|
||||
} catch (\Exception $rollbackEx) {
|
||||
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
||||
}
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -99,6 +99,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<?php if (in_array('articles', $types)) : ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||
<span class="icon-search"></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||
@@ -227,6 +235,55 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse Snapshot Articles Modal -->
|
||||
<div id="mb-snapshot-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
|
||||
<input type="hidden" name="id" id="mb-browse-id" value="">
|
||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||
<div id="mb-browse-loading" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||
</div>
|
||||
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||
<div id="mb-browse-content" style="display:none;">
|
||||
<div class="mb-2">
|
||||
<label class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||
</label>
|
||||
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||
</div>
|
||||
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"></th>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-success" id="mb-browse-restore-btn" disabled>
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Create Snapshot — intercept toolbar button
|
||||
@@ -312,13 +369,124 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Browse Snapshot — click handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-snapshot-browse');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var id = btn.getAttribute('data-id');
|
||||
var desc = btn.getAttribute('data-desc');
|
||||
|
||||
document.getElementById('mb-browse-id').value = id;
|
||||
document.getElementById('mb-browse-title').textContent = 'Browse: ' + desc;
|
||||
|
||||
// Reset modal state
|
||||
document.getElementById('mb-browse-loading').style.display = 'block';
|
||||
document.getElementById('mb-browse-error').style.display = 'none';
|
||||
document.getElementById('mb-browse-content').style.display = 'none';
|
||||
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||
document.getElementById('mb-browse-select-all').checked = false;
|
||||
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||
|
||||
// Fetch articles via AJAX
|
||||
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||
|
||||
fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function(response) { return response.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('mb-browse-error').textContent = data.message;
|
||||
document.getElementById('mb-browse-error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||
|
||||
data.articles.forEach(function(article) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdCheck = document.createElement('td');
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'form-check-input mb-browse-article-cb';
|
||||
cb.name = 'article_ids[]';
|
||||
cb.value = article.id;
|
||||
tdCheck.appendChild(cb);
|
||||
tr.appendChild(tdCheck);
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = article.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = article.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(article.state)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(article.state)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
var tdDate = document.createElement('td');
|
||||
tdDate.textContent = article.created ? article.created.substring(0, 10) : '';
|
||||
tr.appendChild(tdDate);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
|
||||
document.getElementById('mb-browse-content').style.display = 'block';
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
|
||||
document.getElementById('mb-browse-error').style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
// Browse — select all toggle
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id === 'mb-browse-select-all') {
|
||||
var checked = e.target.checked;
|
||||
var checkboxes = document.querySelectorAll('.mb-browse-article-cb');
|
||||
checkboxes.forEach(function(cb) { cb.checked = checked; });
|
||||
updateBrowseRestoreBtn();
|
||||
}
|
||||
|
||||
if (e.target.classList.contains('mb-browse-article-cb')) {
|
||||
updateBrowseRestoreBtn();
|
||||
}
|
||||
});
|
||||
|
||||
function updateBrowseRestoreBtn() {
|
||||
var checked = document.querySelectorAll('.mb-browse-article-cb:checked').length;
|
||||
var btn = document.getElementById('mb-browse-restore-btn');
|
||||
btn.disabled = checked === 0;
|
||||
btn.textContent = checked > 0
|
||||
? <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?> + ' (' + checked + ')'
|
||||
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
||||
}
|
||||
|
||||
// Close modals
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-modal-close') ||
|
||||
e.target.id === 'mb-snapshot-create-modal' ||
|
||||
e.target.id === 'mb-snapshot-restore-modal') {
|
||||
e.target.id === 'mb-snapshot-restore-modal' ||
|
||||
e.target.id === 'mb-snapshot-browse-modal') {
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user