* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * Snapshot engine — creates lightweight JSON snapshots of specific content * types (articles, categories, modules) without touching the filesystem. */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; class SnapshotEngine { private array $log = []; /** Content type => tables mapping */ private const TYPE_TABLES = [ 'articles' => [ '#__content', '#__content_frontpage', ], 'categories' => [ '#__categories', ], 'modules' => [ '#__modules', '#__modules_menu', ], ]; /** Related tables always captured when articles are included */ private const ARTICLE_RELATED = [ '#__workflow_associations', '#__contentitem_tag_map', ]; /** * Create a snapshot of selected content types. * * @param array $contentTypes Types to snapshot: articles, categories, modules * @param string $description User-provided description * * @return array{success: bool, message: string, id?: int} */ public function create(array $contentTypes, string $description = ''): array { $db = Factory::getDbo(); $prefix = $db->getPrefix(); if (empty($contentTypes)) { return ['success' => false, 'message' => 'No content types selected']; } $validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']); if (empty($validTypes)) { return ['success' => false, 'message' => 'No valid content types selected']; } $this->log('Starting snapshot: ' . implode(', ', $validTypes)); try { $data = [ 'version' => 1, 'created' => date('Y-m-d H:i:s'), 'content_types' => array_values($validTypes), 'tables' => [], ]; $counts = [ 'articles' => 0, 'categories' => 0, 'modules' => 0, ]; // Dump each selected content type foreach ($validTypes as $type) { foreach (self::TYPE_TABLES[$type] as $abstractTable) { $realTable = str_replace('#__', $prefix, $abstractTable); $rows = $this->dumpTable($db, $realTable, $abstractTable, $type); $data['tables'][$abstractTable] = $rows; $this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows'); } } // Capture related tables for articles if (in_array('articles', $validTypes)) { $rows = $this->dumpFilteredTable( $db, str_replace('#__', $prefix, '#__workflow_associations'), '#__workflow_associations', 'extension', 'com_content.article' ); $data['tables']['#__workflow_associations'] = $rows; $this->log(' #__workflow_associations: ' . count($rows) . ' rows'); $rows = $this->dumpTagMap($db, $prefix); $data['tables']['#__contentitem_tag_map'] = $rows; $this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows'); } // Count items if (in_array('articles', $validTypes)) { $counts['articles'] = count($data['tables']['#__content'] ?? []); } if (in_array('categories', $validTypes)) { $counts['categories'] = count($data['tables']['#__categories'] ?? []); } if (in_array('modules', $validTypes)) { $counts['modules'] = count($data['tables']['#__modules'] ?? []); } // Write JSON file to backup directory $backupDir = BackupDirectory::getDefaultAbsolute(); BackupDirectory::ensureReady($backupDir); $filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json'; $filePath = $backupDir . '/' . $filename; $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); if ($json === false) { throw new \RuntimeException('Failed to encode snapshot data as JSON'); } if (file_put_contents($filePath, $json) === false) { throw new \RuntimeException('Failed to write snapshot file: ' . $filePath); } $fileSize = strlen($json); $this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)'); // Create database record $now = Factory::getDate()->toSql(); $userId = Factory::getApplication()->getIdentity()->id ?? 0; $record = (object) [ 'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes), 'content_types' => json_encode(array_values($validTypes)), 'status' => 'complete', 'articles_count' => $counts['articles'], 'categories_count' => $counts['categories'], 'modules_count' => $counts['modules'], 'data_file' => $filePath, 'data_size' => $fileSize, 'log' => implode("\n", $this->log), 'created' => $now, 'created_by' => $userId, ]; $db->insertObject('#__mokosuitebackup_snapshots', $record, 'id'); $this->log('Snapshot record created: ID ' . $record->id); return [ 'success' => true, 'message' => sprintf( 'Snapshot created: %d articles, %d categories, %d modules', $counts['articles'], $counts['categories'], $counts['modules'] ), 'id' => $record->id, ]; } catch (\Exception $e) { $this->log('FATAL: ' . $e->getMessage()); return [ 'success' => false, 'message' => 'Snapshot failed: ' . $e->getMessage(), 'log' => implode("\n", $this->log), ]; } } /** * Dump all rows from a table. */ private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array { $query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable)); // Filter categories to com_content only if ($abstractTable === '#__categories' && $type === 'categories') { $query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')); } $db->setQuery($query); return $db->loadAssocList() ?: []; } /** * Dump rows from a table filtered by a column value. */ private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName($realTable)) ->where($db->quoteName($column) . ' = ' . $db->quote($value)); $db->setQuery($query); return $db->loadAssocList() ?: []; } /** * Dump tag map entries for com_content items. */ private function dumpTagMap(object $db, string $prefix): array { $table = $prefix . 'contentitem_tag_map'; $query = $db->getQuery(true) ->select('*') ->from($db->quoteName($table)) ->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%')); $db->setQuery($query); return $db->loadAssocList() ?: []; } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }