* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * Restores content from a snapshot JSON file. * * Two restore modes: * - replace: Truncates target tables then inserts all snapshot rows (clean slate) * - merge: Upserts by primary key — updates existing rows, inserts new ones */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Event\Event; class SnapshotRestoreEngine { private array $log = []; /** Primary key columns for each table */ private const PRIMARY_KEYS = [ '#__content' => 'id', '#__content_frontpage' => 'content_id', '#__categories' => 'id', '#__workflow_associations' => 'item_id', '#__contentitem_tag_map' => null, // composite key, handled specially '#__modules' => 'id', '#__modules_menu' => null, // composite key, handled specially '#__tags' => 'id', '#__fields' => 'id', '#__fields_values' => null, // composite key, handled specially '#__fields_categories' => null, // composite key, handled specially ]; /** * Restore from a snapshot record. * * @param int $snapshotId Snapshot record ID * @param string $mode 'replace' or 'merge' * @param array $contentTypes Which types to restore (empty = all from snapshot) * * @return array{success: bool, message: string, log?: string} */ public function restore(int $snapshotId, string $mode = 'replace', array $contentTypes = []): array { if (!@set_time_limit(0)) { $this->log('WARNING: Could not disable time limit — large restores may timeout'); } if (!@ini_set('memory_limit', '512M')) { $this->log('WARNING: Could not increase memory limit to 512M'); } $mode = $this->normaliseMode($mode); if (!in_array($mode, ['replace', 'merge', 'create', 'duplicate'], true)) { return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode]; } $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']; } $snapshotTypes = $data['content_types'] ?? []; $this->log('Snapshot contains: ' . implode(', ', $snapshotTypes)); $this->log('Restore mode: ' . $mode); // Determine which types to restore if (!empty($contentTypes)) { $restoreTypes = array_intersect($contentTypes, $snapshotTypes); } else { $restoreTypes = $snapshotTypes; } if (empty($restoreTypes)) { return ['success' => false, 'message' => 'No matching content types to restore']; } $this->log('Restoring types: ' . implode(', ', $restoreTypes)); $prefix = $db->getPrefix(); $totalRows = 0; // Build list of tables to restore based on selected types $tablesToRestore = $this->getTablesToRestore($restoreTypes); /* Duplicate mode inserts brand-new records through Joomla's Table API, whose Nested tables (categories, tags) issue LOCK TABLES — which implicitly COMMITS any open transaction in MySQL/InnoDB. Wrapping it in transactionStart()/Rollback() would be a false atomicity promise: the first category/tag store would commit and defeat the rollback. Duplicate mode is therefore best-effort and per-item defensive (a bad item is skipped and logged, never fatal) rather than all-or-nothing. The raw-DB modes (replace/create/merge) remain fully transactional. */ $useTransaction = ($mode !== 'duplicate'); try { if ($useTransaction) { $db->transactionStart(); } if ($mode === 'duplicate') { $totalRows = $this->restoreDuplicate($data, $restoreTypes); } else { foreach ($tablesToRestore as $abstractTable) { if (!isset($data['tables'][$abstractTable])) { $this->log(' Skipping ' . $abstractTable . ' (not in snapshot)'); continue; } $rows = $data['tables'][$abstractTable]; $realTable = str_replace('#__', $prefix, $abstractTable); if ($mode === 'replace') { $rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows); } elseif ($mode === 'create') { $rowCount = $this->restoreCreate($db, $realTable, $abstractTable, $rows); } else { $rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows); } $totalRows += $rowCount; $this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored'); } } if ($useTransaction) { $db->transactionCommit(); } $this->log('Restore complete: ' . $totalRows . ' total rows'); // Send snapshot restore notification try { $profile = NotificationSender::getDefaultProfile(); if ($profile) { $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [ 'mode' => $mode, 'content_types' => $restoreTypes, 'row_count' => $totalRows, 'user' => $userName . ' (ID: ' . $userIdVal . ')', ], implode("\n", $this->log)); } } catch (\Throwable $e) { error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); } // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode); return [ 'success' => true, 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)), 'log' => implode("\n", $this->log), ]; } catch (\Throwable $e) { if ($useTransaction) { try { $db->transactionRollback(); $this->log('Transaction rolled back'); } catch (\Exception $rollbackEx) { $this->log('Rollback failed: ' . $rollbackEx->getMessage()); } } else { $this->log('Duplicate mode is not transactional; records already inserted are left in place.'); } $this->log('FATAL: ' . $e->getMessage()); // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode); return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), 'log' => implode("\n", $this->log), ]; } } /** * Replace mode: delete existing rows, then insert all snapshot rows. */ private function restoreReplace(object $db, string $realTable, string $abstractTable, array $rows): int { // Use DELETE instead of TRUNCATE to stay within transaction $this->truncateFiltered($db, $realTable, $abstractTable, $rows); $count = 0; foreach ($rows as $row) { $obj = (object) $row; $db->insertObject($realTable, $obj); $count++; } return $count; } /** * Merge mode: upsert rows by primary key. */ private function restoreMerge(object $db, string $realTable, string $abstractTable, array $rows): int { $pk = self::PRIMARY_KEYS[$abstractTable] ?? null; $count = 0; foreach ($rows as $row) { $obj = (object) $row; if ($pk !== null && isset($row[$pk])) { // Check if row exists $exists = $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName($realTable)) ->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk])) )->loadResult(); if ($exists) { $db->updateObject($realTable, $obj, $pk); } else { $db->insertObject($realTable, $obj); } } else { // Composite key tables — insert, skip genuine duplicates try { $db->insertObject($realTable, $obj); } catch (\Exception $e) { if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) { $this->log(' Skipped duplicate in ' . $abstractTable); continue; } throw $e; } } $count++; } return $count; } /** * Create mode: insert only rows that don't already exist; never overwrite. * Non-destructive — existing content on the target is left untouched. Used * by master→slave injection when the master must not clobber local edits. */ private function restoreCreate(object $db, string $realTable, string $abstractTable, array $rows): int { $pk = self::PRIMARY_KEYS[$abstractTable] ?? null; $count = 0; foreach ($rows as $row) { $obj = (object) $row; if ($pk !== null && isset($row[$pk])) { $exists = $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName($realTable)) ->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk])) )->loadResult(); if ($exists) { continue; } $db->insertObject($realTable, $obj); } else { // Composite-key tables: insert, skip genuine duplicates. try { $db->insertObject($realTable, $obj); } catch (\Exception $e) { if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) { continue; } throw $e; } } $count++; } return $count; } /** * Normalise external mode names to the engine's internal modes. * overwrite→replace, skip/new→create, copy→duplicate; passthrough otherwise. */ private function normaliseMode(string $mode): string { $map = [ 'overwrite' => 'replace', 'replace' => 'replace', 'update' => 'merge', 'merge' => 'merge', 'skip' => 'create', 'new' => 'create', 'create' => 'create', 'copy' => 'duplicate', 'duplicate' => 'duplicate', ]; return $map[strtolower(trim($mode))] ?? strtolower(trim($mode)); } /** * Duplicate mode: insert snapshot content as BRAND-NEW records (new IDs), * leaving existing content untouched. Uses Joomla's Table API so assets, * UCM entries, category/tag nested-sets and alias uniqueness are handled by * core. Every item is wrapped in try/catch — a bad item is skipped + logged, * never fatal — so a partial import degrades gracefully rather than corrupting. * * Known limits: workflow stage is left to the article table's default; * references to content NOT included in the snapshot keep their original IDs. * * @return int Number of new records created */ private function restoreDuplicate(array $data, array $types): int { $app = Factory::getApplication(); $db = Factory::getDbo(); $tables = $data['tables'] ?? []; $count = 0; $catMap = []; $articleMap = []; $moduleMap = []; // --- Categories (parents before children so the remap resolves) --- if (in_array('categories', $types, true) && !empty($tables['#__categories'])) { $factory = $app->bootComponent('com_categories')->getMVCFactory(); $cats = $tables['#__categories']; usort($cats, static fn ($a, $b) => ((int) ($a['level'] ?? 0)) <=> ((int) ($b['level'] ?? 0))); foreach ($cats as $row) { $oldId = (int) ($row['id'] ?? 0); $oldParent = (int) ($row['parent_id'] ?? 1); $newParent = $catMap[$oldParent] ?? ($oldParent > 1 ? $oldParent : 1); unset($row['id'], $row['asset_id'], $row['lft'], $row['rgt'], $row['level'], $row['path']); $row['extension'] = 'com_content'; $row['parent_id'] = $newParent; $row['alias'] = $this->uniqueAlias($db, '#__categories', (string) ($row['alias'] ?? ''), ['extension' => 'com_content']); try { $table = $factory->createTable('Category', 'Administrator'); $table->setLocation($newParent, 'last-child'); if ($table->bind($row) && $table->check() && $table->store()) { $catMap[$oldId] = (int) $table->id; $count++; } else { $this->log(' Category skipped: ' . $table->getError()); } } catch (\Throwable $e) { $this->log(' Category "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); } } } // --- Articles --- if (in_array('articles', $types, true) && !empty($tables['#__content'])) { $factory = $app->bootComponent('com_content')->getMVCFactory(); $featured = []; foreach ($tables['#__content_frontpage'] ?? [] as $fp) { $featured[(int) ($fp['content_id'] ?? 0)] = true; } $tagTitles = $this->buildArticleTagTitles($tables); foreach ($tables['#__content'] as $row) { $oldId = (int) ($row['id'] ?? 0); $oldCat = (int) ($row['catid'] ?? 0); unset($row['id'], $row['asset_id']); if (isset($catMap[$oldCat])) { $row['catid'] = $catMap[$oldCat]; } $row['alias'] = $this->uniqueAlias($db, '#__content', (string) ($row['alias'] ?? ''), ['catid' => (int) ($row['catid'] ?? 0)]); try { $table = $factory->createTable('Article', 'Administrator'); $tagIds = $this->resolveTagIds($db, $app, $tagTitles[$oldId] ?? []); if ($tagIds) { $table->newTags = $tagIds; } if ($table->bind($row) && $table->check() && $table->store()) { $newId = (int) $table->id; $articleMap[$oldId] = $newId; $count++; if (!empty($featured[$oldId])) { try { $db->insertObject($db->getPrefix() . 'content_frontpage', (object) ['content_id' => $newId, 'ordering' => 0]); } catch (\Throwable $e) { // featured-ordering row is non-critical } } } else { $this->log(' Article skipped: ' . $table->getError()); } } catch (\Throwable $e) { $this->log(' Article "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); } } // Custom-field values for the duplicated articles. foreach ($tables['#__fields_values'] ?? [] as $fv) { $oldItem = (int) ($fv['item_id'] ?? 0); if (!isset($articleMap[$oldItem])) { continue; } $fv['item_id'] = (string) $articleMap[$oldItem]; try { $db->insertObject($db->getPrefix() . 'fields_values', (object) $fv); } catch (\Throwable $e) { // duplicate/invalid field value — skip } } } // --- Modules --- if (in_array('modules', $types, true) && !empty($tables['#__modules'])) { $factory = $app->bootComponent('com_modules')->getMVCFactory(); foreach ($tables['#__modules'] as $row) { $oldId = (int) ($row['id'] ?? 0); unset($row['id'], $row['asset_id']); try { $table = $factory->createTable('Module', 'Administrator'); if ($table->bind($row) && $table->check() && $table->store()) { $moduleMap[$oldId] = (int) $table->id; $count++; } else { $this->log(' Module skipped: ' . $table->getError()); } } catch (\Throwable $e) { $this->log(' Module "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); } } foreach ($tables['#__modules_menu'] ?? [] as $mm) { $oldMod = (int) ($mm['moduleid'] ?? 0); if (!isset($moduleMap[$oldMod])) { continue; } try { $db->insertObject($db->getPrefix() . 'modules_menu', (object) ['moduleid' => $moduleMap[$oldMod], 'menuid' => (int) ($mm['menuid'] ?? 0)]); } catch (\Throwable $e) { // duplicate assignment — skip } } } $this->log('Duplicated ' . $count . ' new records (categories: ' . count($catMap) . ', articles: ' . count($articleMap) . ', modules: ' . count($moduleMap) . ')'); return $count; } /** * Make an alias unique within a scope by appending -2, -3, … if needed. */ private function uniqueAlias(object $db, string $abstractTable, string $alias, array $scope): string { $alias = $alias !== '' ? $alias : 'item'; $table = str_replace('#__', $db->getPrefix(), $abstractTable); $base = $alias; $n = 1; while (true) { $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName($table)) ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); foreach ($scope as $col => $val) { $query->where($db->quoteName($col) . ' = ' . $db->quote($val)); } if ((int) $db->setQuery($query)->loadResult() === 0) { return $alias; } $n++; $alias = $base . '-' . $n; if ($n > 100) { return $base . '-' . substr(md5((string) mt_rand()), 0, 6); } } } /** * Map old article id → its tag titles, from the snapshot's tag map + tags. * * @return array */ private function buildArticleTagTitles(array $tables): array { $tagTitle = []; foreach ($tables['#__tags'] ?? [] as $tag) { $tagTitle[(int) ($tag['id'] ?? 0)] = (string) ($tag['title'] ?? ''); } $byArticle = []; foreach ($tables['#__contentitem_tag_map'] ?? [] as $map) { if (strpos((string) ($map['type_alias'] ?? ''), 'com_content.article') !== 0) { continue; } $articleId = (int) ($map['content_item_id'] ?? 0); $title = $tagTitle[(int) ($map['tag_id'] ?? 0)] ?? ''; if ($articleId && $title !== '') { $byArticle[$articleId][] = $title; } } return $byArticle; } /** * Resolve tag titles to target tag IDs, creating any that don't exist. * * @return int[] */ private function resolveTagIds(object $db, object $app, array $titles): array { $ids = []; foreach (array_unique($titles) as $title) { try { $existing = $db->setQuery( $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__tags')) ->where($db->quoteName('title') . ' = ' . $db->quote($title)) )->loadResult(); if ($existing) { $ids[] = (int) $existing; continue; } $table = $app->bootComponent('com_tags')->getMVCFactory()->createTable('Tag', 'Administrator'); $table->setLocation(1, 'last-child'); if ($table->bind(['title' => $title, 'alias' => '', 'published' => 1, 'parent_id' => 1]) && $table->check() && $table->store()) { $ids[] = (int) $table->id; } } catch (\Throwable $e) { // tag resolution is best-effort } } return $ids; } /** * Delete rows from a table, scoping to relevant content only. * * Shared tables (#__categories, #__modules, etc.) are filtered so * only the rows belonging to our content types are deleted — never * the entire table. */ private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): void { $query = $db->getQuery(true)->delete($db->quoteName($realTable)); switch ($abstractTable) { case '#__categories': $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')); break; case '#__workflow_associations': $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content.article')); break; case '#__contentitem_tag_map': $query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%')); break; case '#__modules': // Only delete modules that exist in the snapshot — never wipe all site modules $ids = array_filter(array_column($rows, 'id')); if (empty($ids)) { return; } $ids = array_map('intval', $ids); $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')'); break; case '#__modules_menu': // Only delete menu assignments for modules in the snapshot $moduleIds = array_filter(array_column($rows, 'moduleid')); if (empty($moduleIds)) { return; } $moduleIds = array_map('intval', array_unique($moduleIds)); $query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')'); break; case '#__tags': // Only delete tags that exist in the snapshot — never wipe all tags $ids = array_filter(array_column($rows, 'id')); if (empty($ids)) { return; } $ids = array_map('intval', $ids); $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')'); break; case '#__fields': // Only delete custom fields scoped to com_content.article $query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); break; case '#__fields_values': // Only delete field values for com_content.article fields $prefix = $db->getPrefix(); $fTable = $prefix . 'fields'; $subQuery = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName($fTable)) ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); break; case '#__fields_categories': // Delete field-category mappings for com_content.article fields only $prefix = $db->getPrefix(); $fTable = $prefix . 'fields'; $subQuery = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName($fTable)) ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); break; // #__content and #__content_frontpage are fully owned by com_content default: break; } $db->setQuery($query); $db->execute(); } /** * Build list of abstract table names for the given content types. */ private function getTablesToRestore(array $types): array { $tables = []; if (in_array('articles', $types)) { $tables[] = '#__content'; $tables[] = '#__content_frontpage'; $tables[] = '#__workflow_associations'; $tables[] = '#__contentitem_tag_map'; $tables[] = '#__tags'; $tables[] = '#__fields'; $tables[] = '#__fields_values'; $tables[] = '#__fields_categories'; } if (in_array('categories', $types)) { $tables[] = '#__categories'; } if (in_array('modules', $types)) { $tables[] = '#__modules'; $tables[] = '#__modules_menu'; } 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()); } // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective'); 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()); // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective'); return [ 'success' => false, 'message' => 'Selective restore failed: ' . $e->getMessage(), 'log' => implode("\n", $this->log), ]; } } /** * Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react. */ private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void { try { $app = Factory::getApplication(); $event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [ 'success' => $success, 'snapshot_id' => $snapshotId, 'mode' => $mode, ]); $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event); } catch (\Throwable $e) { // Never let a listener failure break the restore result, but log it error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage()); } } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }