From 4e07eabc0f0468c32d1e941a36f40a5ab07bfade Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 10 Jul 2026 12:00:28 -0500 Subject: [PATCH] feat(#237): implement duplicate snapshot mode (insert as new, remapped IDs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the deferred 'duplicate' conflict mode. Inserts snapshot content as brand-new records with remapped IDs, leaving existing content untouched — for master->slave 'push as new copies'. - Uses Joomla's Table API (bootComponent MVCFactory) for Article, Category and Module so assets, UCM entries, category/tag nested-sets and alias uniqueness are handled by core rather than hand-rolled. - Remaps category parent_id (parents first), article catid, module menu assignments, and custom-field-value item_id; re-features articles. - Tags are reused/created by title (resolveTagIds) and assigned via the article table's newTags so UCM/tag-map stay correct. - uniqueAlias() avoids alias collisions per scope. - DEFENSIVE: every item is wrapped in try/catch — a bad item is skipped and logged, never fatal — so an untested/partial import degrades gracefully instead of corrupting content. NOTE: still needs a real test pass on a dev site before relying on it. Refs #237 Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i --- CHANGELOG.md | 2 +- .../language/en-GB/com_mokosuitebackup.ini | 2 +- .../src/Engine/SnapshotRestoreEngine.php | 284 +++++++++++++++++- 3 files changed, 275 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac80c644..f6bae101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +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.) +- **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. A third **duplicate** mode inserts everything as brand-new records with **remapped IDs** (articles, categories, modules — plus their tags, custom-field values and featured flags) using Joomla's Table API so assets/UCM/nested-sets stay valid; each item is inserted defensively (a bad item is skipped and logged, never fatal). - 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/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 25bcbba0..191889e2 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -504,7 +504,7 @@ COM_MOKOJOOMBACKUP_SNAPSHOT_IMPORT_FILE="Snapshot file (.msbsnap)" COM_MOKOJOOMBACKUP_SNAPSHOT_IMPORT_NO_FILE="No snapshot file was uploaded, or the upload failed." COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_OVERWRITE="Overwrite — replace matching items (master wins)" COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_CREATE="Create — skip existing, add only new items" -COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_DUPLICATE="Duplicate — add as new copies (coming soon)" +COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_DUPLICATE="Duplicate — add as new copies (remapped IDs)" COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_TRANSFER="Snapshot Transfer / Injection" COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_INJECT_MODE="Default Conflict Mode" COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_INJECT_MODE_DESC="Default mode used when a snapshot is injected via the API without an explicit mode." diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 40fdd32a..9e32577a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -61,17 +61,7 @@ class SnapshotRestoreEngine $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)) { + if (!in_array($mode, ['replace', 'merge', 'create', 'duplicate'], true)) { return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode]; } @@ -141,6 +131,9 @@ class SnapshotRestoreEngine // Build list of tables to restore based on selected types $tablesToRestore = $this->getTablesToRestore($restoreTypes); + 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)'); @@ -161,6 +154,7 @@ class SnapshotRestoreEngine $totalRows += $rowCount; $this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored'); } + } $db->transactionCommit(); @@ -344,6 +338,274 @@ class SnapshotRestoreEngine 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. *