64ffbb9d61
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m10s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
#100: Run Backup button on profiles list (per-row) and edit toolbar, backup count badge linking to filtered backups view, View Backups toolbar button on profile edit. #101: Profile → filtered backup list link (included in #100). #104: Snapshot browse modal now shows tabbed view (Articles, Categories, Modules) with item counts. AjaxController returns all content types. Categories show indented hierarchy. #108: "Do not navigate away or close this window" warning banner added to both backup and restore progress modals. #110: Joomla Action Logs integration — RestoreEngine, SnapshotEngine, and SnapshotRestoreEngine now dispatch events that the actionlog plugin logs to #__action_logs. Closes #100, closes #101, closes #104, closes #108, closes #110
363 lines
11 KiB
PHP
363 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* 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;
|
|
use Joomla\Event\Event;
|
|
|
|
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',
|
|
'#__tags',
|
|
'#__fields',
|
|
'#__fields_values',
|
|
'#__fields_categories',
|
|
];
|
|
|
|
/**
|
|
* 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');
|
|
|
|
// Tags — dump all (shared, small table)
|
|
$rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__tags'), '#__tags', 'articles');
|
|
$data['tables']['#__tags'] = $rows;
|
|
$this->log(' #__tags: ' . count($rows) . ' rows');
|
|
|
|
// Custom fields — only com_content.article context
|
|
$rows = $this->dumpFilteredTable(
|
|
$db,
|
|
str_replace('#__', $prefix, '#__fields'),
|
|
'#__fields',
|
|
'context',
|
|
'com_content.article'
|
|
);
|
|
$data['tables']['#__fields'] = $rows;
|
|
$this->log(' #__fields: ' . count($rows) . ' rows');
|
|
|
|
// Field values — only for com_content.article fields (table is shared across extensions)
|
|
$rows = $this->dumpFieldValues($db, $prefix);
|
|
$data['tables']['#__fields_values'] = $rows;
|
|
$this->log(' #__fields_values: ' . count($rows) . ' rows');
|
|
|
|
// Field-category mappings — only for com_content.article fields
|
|
$rows = $this->dumpFieldCategories($db, $prefix);
|
|
$data['tables']['#__fields_categories'] = $rows;
|
|
$this->log(' #__fields_categories: ' . 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);
|
|
|
|
// Send snapshot creation notification
|
|
try {
|
|
$profile = NotificationSender::getDefaultProfile();
|
|
|
|
if ($profile) {
|
|
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
|
|
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
|
|
|
|
NotificationSender::sendRestoreNotification($profile, 'snapshot_create', [
|
|
'content_types' => array_values($validTypes),
|
|
'articles_count' => $counts['articles'],
|
|
'categories_count' => $counts['categories'],
|
|
'modules_count' => $counts['modules'],
|
|
'user' => $userName . ' (ID: ' . $userIdVal . ')',
|
|
], implode("\n", $this->log));
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
|
}
|
|
|
|
// Dispatch event for actionlog and other listeners
|
|
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
|
|
|
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());
|
|
|
|
// Dispatch event for actionlog and other listeners
|
|
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
|
|
|
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() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Dump field-category mappings for com_content.article fields.
|
|
*
|
|
* Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article')
|
|
*/
|
|
/**
|
|
* Dump field values only for com_content.article fields.
|
|
*/
|
|
private function dumpFieldValues(object $db, string $prefix): array
|
|
{
|
|
$fvTable = $prefix . 'fields_values';
|
|
$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 = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName($fvTable))
|
|
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadAssocList() ?: [];
|
|
}
|
|
|
|
private function dumpFieldCategories(object $db, string $prefix): array
|
|
{
|
|
$fcTable = $prefix . 'fields_categories';
|
|
$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 = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName($fcTable))
|
|
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadAssocList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
|
*/
|
|
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
|
{
|
|
try {
|
|
$app = Factory::getApplication();
|
|
|
|
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
|
'success' => $success,
|
|
'snapshot_id' => $snapshotId,
|
|
'content_types' => $contentTypes,
|
|
]);
|
|
|
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
|
} catch (\Throwable $e) {
|
|
// Never let a listener failure break the snapshot result, but log it
|
|
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function log(string $message): void
|
|
{
|
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
|
}
|
|
}
|