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 260bafd..67fad73 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -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" 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 b2a7fbe..f6a8606 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -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" diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index bff38b2..2af280e 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -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. */ diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php index 3aa1678..057b30e 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -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. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 329ecdb..7bb9014 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -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; diff --git a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php index 1f6b964..91f2311 100644 --- a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php @@ -99,6 +99,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));