From 49fcd5e63c014afef9d4adf49c61b7dd962e8b97 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 10 Jul 2026 11:40:51 -0500 Subject: [PATCH] feat(#237): portable snapshot transfer + master->slave injection API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first increment of snapshot transfer: - SnapshotPortable engine: package a snapshot into a portable .msbsnap (zip of manifest.json + snapshot.json + sha256), and ingest one back (from a zip upload or an inline payload) by materialising a local snapshot record. - Admin: per-row 'Download portable snapshot' + an Import modal (upload .msbsnap) on the Snapshots page. - API: POST /snapshot/inject — master pushes a snapshot payload into a slave's Web Services API; gated by a new 'Allow Snapshot Injection' option. Route added to the webservices plugin. - Restore engine: new 'create' mode (skip existing, insert only new) and mode-name normalisation (overwrite->replace, create, duplicate). The 'duplicate' (insert-as-new-with-remapped-IDs) mode returns a clear 'not yet available' — deferred to a dedicated, tested pass. - Options: 'Snapshot Transfer' fieldset — default conflict mode + allow-inject toggle. Language keys added (en-GB fallback). Reuses the existing mokosuitebackup.snapshot.manage ACL. NOTE: untested end-to-end (no dev build this session) — verify download/import/inject on a dev build. Refs #237 Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i --- CHANGELOG.md | 1 + .../src/Controller/SnapshotsController.php | 88 +++++- .../packages/com_mokosuitebackup/config.xml | 25 ++ .../language/en-GB/com_mokosuitebackup.ini | 16 + .../src/Controller/SnapshotsController.php | 90 ++++++ .../src/Engine/SnapshotPortable.php | 285 ++++++++++++++++++ .../src/Engine/SnapshotRestoreEngine.php | 82 ++++- .../tmpl/snapshots/default.php | 39 +++ .../Extension/MokoSuiteBackupWebServices.php | 11 + 9 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fec5ae0..ac80c644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Snapshot transfer + master→slave injection (#237):** download a content snapshot as a portable **`.msbsnap`** file (zip of `manifest.json` + `snapshot.json` + a sha256 integrity check); **import** one on another site via a new Import button on the Snapshots page (materialises a local snapshot record you can then restore); and a **direct injection API** — `POST /api/index.php/v1/mokosuitebackup/snapshot/inject` — so a **master** site can push a snapshot straight into a **slave**'s Web Services API. Two conflict modes ship now: **overwrite** (replace matching items by ID — master wins) and **create** (skip existing, add only new), selectable per inject call or defaulted in **Options → Snapshot Transfer**. A receiving site must opt in via the new *Allow Snapshot Injection* setting. (The **duplicate** mode — insert as new copies with remapped IDs — is recognised but returns a clear "not yet available"; it's the next dedicated, test-worthy piece because it needs article/category/tag/field ID remapping.) - Full-screen backup now also fronts **extension updates and uninstalls** (Extensions → Update / Manage), not just core Joomla updates. Because `com_installer`'s `update.update` / `manage.remove` are POST actions (CSRF token + a checked `cid[]` list) that a server-side redirect can't cleanly resume, this is done client-side: the toolbar Update/Uninstall click is intercepted, the selection is captured, the full-screen backup runs, and on return the original selection is restored and the real POST form is re-submitted. Gated by `backup_before_update` / `backup_before_uninstall`, super-user only, and skipped if a backup already ran this session. - Manual **"Backup Now"** completion now offers a **View backup record** button (links straight to the record that was just created). It appears only for manual backups — the pre-update / pre-uninstall flow hands control back to Joomla instead. - **Full-screen backup screen** (`view=runbackup`) for both pre-update and manual backups, modelled on Akeeba's Backup-on-Update — replaces the earlier popup-modal approach. Clicking Joomla's **Install the update** now redirects (server-side, Akeeba-style) to a dedicated full-page backup screen that runs the stepped backup with a real progress bar and then **automatically continues the update** via a validated `returnurl` (flagged `is_backed_up=1` so the backup isn't repeated). Crucially, **no backup runs synchronously inside the update request** — which is what white-screened large sites. The dashboard **Backup Now** now opens the same full-screen screen instead of an inline modal. (#196) diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php index 0d77d965..f3aa0f3a 100644 --- a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php @@ -21,9 +21,11 @@ namespace Joomla\Component\MokoSuiteBackup\Api\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\ApiController; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotPortable; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; class SnapshotsController extends ApiController @@ -159,11 +161,11 @@ class SnapshotsController extends ApiController $data = json_decode($this->input->json->getRaw(), true) ?: []; - $mode = $data['mode'] ?? 'replace'; + $mode = (string) ($data['mode'] ?? 'replace'); $contentTypes = $data['content_types'] ?? []; - // Enforce valid restore mode - if (!in_array($mode, ['replace', 'merge'], true)) { + // Accept the external mode names too; the engine normalises + validates. + if (!in_array($mode, ['replace', 'merge', 'overwrite', 'create', 'duplicate'], true)) { $mode = 'replace'; } @@ -183,6 +185,86 @@ class SnapshotsController extends ApiController return $this; } + /** + * Inject a snapshot payload directly into this site (master → slave). + * POST /api/index.php/v1/mokosuitebackup/snapshot/inject + * + * Body: { + * "snapshot": { version, content_types, tables }, // the payload (required) + * "mode": "overwrite" | "create" | "duplicate", // optional; defaults to the site's snapshot_inject_mode + * "content_types": [ ... ], // optional filter + * "description": "..." // optional + * } + */ + public function inject(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $params = ComponentHelper::getParams('com_mokosuitebackup'); + + // The receiving (slave) site must explicitly allow injection. + if (!(int) $params->get('snapshot_allow_inject', 0)) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Snapshot injection is disabled on this site']]]); + $this->app->close(); + + return $this; + } + + $body = json_decode($this->input->json->getRaw(), true) ?: []; + $snapshot = $body['snapshot'] ?? null; + + if (!is_array($snapshot)) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'Missing "snapshot" payload']]]); + $this->app->close(); + + return $this; + } + + // Explicit mode wins; otherwise the site default from Options. + $mode = (string) ($body['mode'] ?? $params->get('snapshot_inject_mode', 'overwrite')); + $contentTypes = $body['content_types'] ?? []; + $description = (string) ($body['description'] ?? 'Injected snapshot'); + + // Materialise a local record from the payload, then restore it. + $ingest = SnapshotPortable::ingestData($snapshot, $description); + + if (empty($ingest['success'])) { + $this->app->setHeader('status', 422); + echo json_encode(['errors' => [['title' => $ingest['message']]]]); + $this->app->close(); + + return $this; + } + + $engine = new SnapshotRestoreEngine(); + $result = $engine->restore((int) $ingest['id'], $mode, $contentTypes); + $result['snapshot_id'] = (int) $ingest['id']; + + if (!empty($ingest['warnings'])) { + $result['warnings'] = array_merge($result['warnings'] ?? [], $ingest['warnings']); + } + + if ($result['success']) { + $this->app->setHeader('status', 200); + echo json_encode(['data' => $result]); + } else { + $this->app->setHeader('status', 500); + echo json_encode(['errors' => [['title' => $result['message']]], 'data' => $result]); + } + + $this->app->close(); + + return $this; + } + /** * Delete a snapshot record and its data file (DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id) */ diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 84db7fca..f0ce6bda 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -206,6 +206,31 @@ /> +
+ + + + + + + + + +
+
checkToken('get'); + + 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); + $item = $id ? $this->getModel('Snapshot')->getItem($id) : null; + + if (!$item || empty($item->id)) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NOT_FOUND'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + try { + $file = SnapshotPortable::package($item); + } catch (\Throwable $e) { + $this->setMessage($e->getMessage(), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $name = SnapshotPortable::filename($item); + + while (@ob_end_clean()) { + // flush buffers + } + + header('Content-Type: application/octet-stream'); + header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($name)); + header('Content-Length: ' . filesize($file)); + header('Cache-Control: no-cache, must-revalidate'); + + readfile($file); + @unlink($file); + + $this->app->close(); + } + + /** + * Import an uploaded .msbsnap file — materialise a local snapshot record. + */ + public function import(): 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; + } + + $file = $this->input->files->get('msbsnap_file', null, 'raw'); + + if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? 1) !== 0 + || !is_uploaded_file($file['tmp_name'])) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_IMPORT_NO_FILE'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $result = SnapshotPortable::ingestZip($file['tmp_name']); + + if (!empty($result['success'])) { + foreach ($result['warnings'] ?? [] as $warning) { + $this->app->enqueueMessage($warning, 'warning'); + } + + $this->setMessage($result['message']); + } else { + $this->setMessage($result['message'], 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } + /** * Create a new content snapshot. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php new file mode 100644 index 00000000..97627d22 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php @@ -0,0 +1,285 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Portable snapshot transfer format (.msbsnap). + * + * A .msbsnap is a zip containing: + * - manifest.json — format/version, generator, source (host/Joomla/prefix), + * content summary, and a sha256 of the payload. + * - snapshot.json — the snapshot's content data (verbatim, same shape the + * snapshot engine writes: {version, content_types, tables}). + * + * package() builds a .msbsnap for an existing snapshot record (for download). + * ingestZip() validates an uploaded .msbsnap and materialises a local snapshot + * record from it (so the normal restore UX can act on it). + * ingestData() materialises a record from an already-parsed data array (used by + * the master→slave injection API, which receives the payload inline). + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +final class SnapshotPortable +{ + public const FORMAT = 'msbsnap'; + public const FORMAT_VERSION = 1; + public const MANIFEST_NAME = 'manifest.json'; + public const PAYLOAD_NAME = 'snapshot.json'; + + /** + * Build a portable .msbsnap zip for a snapshot record. + * + * @param object $snapshot Row from #__mokosuitebackup_snapshots (needs + * data_file, description, content_types, *_count) + * + * @return string Absolute path to a temp .msbsnap file (caller unlinks it) + * + * @throws \RuntimeException + */ + public static function package(object $snapshot): string + { + $dataFile = (string) ($snapshot->data_file ?? ''); + + if ($dataFile === '' || !is_file($dataFile) || !is_readable($dataFile)) { + throw new \RuntimeException('Snapshot data file is missing or unreadable.'); + } + + $payload = file_get_contents($dataFile); + + if ($payload === false) { + throw new \RuntimeException('Could not read snapshot data file.'); + } + + $manifest = [ + 'format' => self::FORMAT, + 'format_version' => self::FORMAT_VERSION, + 'generator' => 'MokoSuiteBackup', + 'created' => Factory::getDate()->toISO8601(), + 'source' => [ + 'host' => self::siteHost(), + 'joomla_version' => JVERSION, + 'db_prefix' => Factory::getDbo()->getPrefix(), + ], + 'snapshot' => [ + 'description' => (string) ($snapshot->description ?? ''), + 'content_types' => json_decode((string) ($snapshot->content_types ?? '[]'), true) ?: [], + 'articles_count' => (int) ($snapshot->articles_count ?? 0), + 'categories_count' => (int) ($snapshot->categories_count ?? 0), + 'modules_count' => (int) ($snapshot->modules_count ?? 0), + 'created' => (string) ($snapshot->created ?? ''), + ], + 'payload' => [ + 'file' => self::PAYLOAD_NAME, + 'sha256' => hash('sha256', $payload), + 'size' => strlen($payload), + ], + ]; + + $tmp = tempnam(sys_get_temp_dir(), 'msbsnap_'); + + if ($tmp === false) { + throw new \RuntimeException('Could not create a temporary file.'); + } + + $zip = new \ZipArchive(); + + if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) { + @unlink($tmp); + + throw new \RuntimeException('Could not create the .msbsnap archive.'); + } + + $zip->addFromString(self::MANIFEST_NAME, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $zip->addFromString(self::PAYLOAD_NAME, $payload); + $zip->close(); + + return $tmp; + } + + /** + * A friendly download filename for a snapshot's portable file. + */ + public static function filename(object $snapshot): string + { + $id = (int) ($snapshot->id ?? 0); + $date = date('Ymd_His'); + + return 'snapshot-' . $id . '-' . $date . '.msbsnap'; + } + + /** + * Ingest an uploaded .msbsnap file: validate it and materialise a local + * snapshot record so the normal restore UX can act on it. + * + * @return array{success:bool,message:string,id?:int,warnings?:array} + */ + public static function ingestZip(string $zipPath, string $descriptionPrefix = 'Imported'): array + { + if (!is_file($zipPath) || !is_readable($zipPath)) { + return ['success' => false, 'message' => 'Uploaded file is missing or unreadable.']; + } + + $zip = new \ZipArchive(); + + if ($zip->open($zipPath) !== true) { + return ['success' => false, 'message' => 'Not a valid .msbsnap archive.']; + } + + try { + $manifestRaw = $zip->getFromName(self::MANIFEST_NAME); + $payloadRaw = $zip->getFromName(self::PAYLOAD_NAME); + } finally { + $zip->close(); + } + + if ($manifestRaw === false || $payloadRaw === false) { + return ['success' => false, 'message' => 'Archive is missing its manifest or payload.']; + } + + $manifest = json_decode($manifestRaw, true); + + if (!is_array($manifest) || ($manifest['format'] ?? '') !== self::FORMAT) { + return ['success' => false, 'message' => 'Unrecognised snapshot file format.']; + } + + if ((int) ($manifest['format_version'] ?? 0) > self::FORMAT_VERSION) { + return ['success' => false, 'message' => 'This snapshot file was made by a newer version of MokoSuiteBackup.']; + } + + $expected = (string) ($manifest['payload']['sha256'] ?? ''); + + if ($expected !== '' && !hash_equals($expected, hash('sha256', $payloadRaw))) { + return ['success' => false, 'message' => 'Snapshot payload failed its integrity (checksum) check.']; + } + + $data = json_decode($payloadRaw, true); + + if (!self::isValidData($data)) { + return ['success' => false, 'message' => 'Snapshot payload is not valid content data.']; + } + + $warnings = self::compatWarnings($manifest); + $srcHost = (string) ($manifest['source']['host'] ?? ''); + $srcDesc = (string) ($manifest['snapshot']['description'] ?? ''); + $descr = trim($descriptionPrefix . ': ' . ($srcDesc !== '' ? $srcDesc : 'snapshot') + . ($srcHost !== '' ? ' (from ' . $srcHost . ')' : '')); + + $result = self::ingestData($data, $descr); + + if (!empty($warnings) && !empty($result['success'])) { + $result['warnings'] = array_merge($result['warnings'] ?? [], $warnings); + } + + return $result; + } + + /** + * Materialise a snapshot record from an already-parsed data array. + * Used by the injection API (payload arrives inline, not as a zip). + * + * @return array{success:bool,message:string,id?:int,warnings?:array} + */ + public static function ingestData(array $data, string $description): array + { + if (!self::isValidData($data)) { + return ['success' => false, 'message' => 'Snapshot data is invalid or empty.']; + } + + try { + $backupDir = BackupDirectory::getDefaultAbsolute(); + BackupDirectory::ensureReady($backupDir); + + $filename = 'snapshot_' . date('Ymd_His') . '_imported.json'; + $filePath = $backupDir . '/' . $filename; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + if ($json === false || file_put_contents($filePath, $json) === false) { + return ['success' => false, 'message' => 'Could not write the imported snapshot file.']; + } + + $tables = $data['tables'] ?? []; + $db = Factory::getDbo(); + + $record = (object) [ + 'description' => $description !== '' ? $description : 'Imported snapshot', + 'content_types' => json_encode(array_values($data['content_types'] ?? [])), + 'status' => 'complete', + 'articles_count' => count($tables['#__content'] ?? []), + 'categories_count' => count($tables['#__categories'] ?? []), + 'modules_count' => count($tables['#__modules'] ?? []), + 'data_file' => $filePath, + 'data_size' => strlen($json), + 'log' => 'Imported ' . Factory::getDate()->toSql(), + 'created' => Factory::getDate()->toSql(), + 'created_by' => (int) (Factory::getApplication()->getIdentity()->id ?? 0), + ]; + + $db->insertObject('#__mokosuitebackup_snapshots', $record, 'id'); + + return [ + 'success' => true, + 'message' => 'Snapshot imported (record #' . (int) $record->id . ').', + 'id' => (int) $record->id, + 'warnings' => [], + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } + + /** + * Structural check for a snapshot data payload. + */ + private static function isValidData($data): bool + { + return is_array($data) + && isset($data['tables']) && is_array($data['tables']) + && isset($data['content_types']) && is_array($data['content_types']); + } + + /** + * Best-effort compatibility warnings (non-blocking). + * + * @return string[] + */ + private static function compatWarnings(array $manifest): array + { + $warnings = []; + $srcJoomla = (string) ($manifest['source']['joomla_version'] ?? ''); + $srcMajor = $srcJoomla !== '' ? (int) explode('.', $srcJoomla)[0] : 0; + $thisMajor = (int) explode('.', JVERSION)[0]; + + if ($srcMajor > 0 && $srcMajor !== $thisMajor) { + $warnings[] = 'Snapshot was created on Joomla ' . $srcJoomla . ' but this site is Joomla ' . JVERSION + . ' — content tables may differ; review after restoring.'; + } + + return $warnings; + } + + /** + * The current site's host, for the manifest (best-effort, informational). + */ + private static function siteHost(): string + { + try { + $host = Uri::getInstance(Uri::root())->getHost(); + } catch (\Throwable $e) { + $host = ''; + } + + return $host !== '' ? $host : (string) (Factory::getApplication()->get('sitename', 'site')); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 0bf3c300..40fdd32a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -59,7 +59,19 @@ class SnapshotRestoreEngine $this->log('WARNING: Could not increase memory limit to 512M'); } - if (!in_array($mode, ['replace', 'merge'])) { + $mode = $this->normaliseMode($mode); + + if ($mode === 'duplicate') { + // Insert-as-new-with-remapped-IDs is a separate, heavier engine + // (article + category nested-set + tag/field/workflow reference + // remapping) that needs its own tested implementation. + return [ + 'success' => false, + 'message' => 'Duplicate mode (insert as new with remapped IDs) is not available yet — use overwrite or create.', + ]; + } + + if (!in_array($mode, ['replace', 'merge', 'create'], true)) { return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode]; } @@ -140,6 +152,8 @@ class SnapshotRestoreEngine 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); } @@ -264,6 +278,72 @@ class SnapshotRestoreEngine 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)); + } + /** * Delete rows from a table, scoping to relevant content only. * diff --git a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php index 53fce3e0..f2508b97 100644 --- a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php @@ -27,6 +27,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
+ +
+ +
+ items)) : ?>
@@ -112,6 +120,11 @@ $listDirn = $this->escape($this->state->get('list.direction')); title=""> + + + id; ?> @@ -131,6 +144,32 @@ $listDirn = $this->escape($this->state->get('list.direction'));
+ + +