feat(#237): implement duplicate snapshot mode (insert as new, remapped IDs)
Universal: Auto Version Bump / Version Bump (push) Successful in 7s

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
This commit is contained in:
2026-07-10 12:00:28 -05:00
parent 2408476908
commit 4e07eabc0f
3 changed files with 275 additions and 13 deletions
@@ -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."
@@ -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<int,string[]>
*/
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.
*