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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user