diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9803a10..f7adef3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
+### Changed
+- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
+
+### Added
+- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
+- Snapshot retention settings: max count and max age with automatic cleanup (#63)
+
## [01.27.03] --- 2026-06-21
## [01.27.03] --- 2026-06-21
diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml
index 60428d2..6fdd1ad 100644
--- a/source/packages/com_mokosuitebackup/config.xml
+++ b/source/packages/com_mokosuitebackup/config.xml
@@ -118,6 +118,27 @@
/>
+
+
+
+
+
remote_storage ?? 'none';
if ($remoteStorage !== 'none') {
- $this->log('Starting remote upload (' . $remoteStorage . ')...');
- $uploader = $this->createUploader($remoteStorage, $profile);
- $uploadResult = $uploader->upload($archivePath, $archiveName);
+ try {
+ $this->log('Starting remote upload (' . $remoteStorage . ')...');
+ $uploader = $this->createUploader($remoteStorage, $profile);
+ $uploadResult = $uploader->upload($archivePath, $archiveName);
- if ($uploadResult['success']) {
- $remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
- $this->log('Remote upload complete: ' . $uploadResult['message']);
+ if ($uploadResult['success']) {
+ $remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
+ $this->log('Remote upload complete: ' . $uploadResult['message']);
- // Delete local copy if configured
- if (empty($profile->remote_keep_local) && is_file($archivePath)) {
- @unlink($archivePath);
- $this->log('Local copy removed (remote_keep_local = off)');
+ // Delete local copy if configured
+ if (empty($profile->remote_keep_local) && is_file($archivePath)) {
+ @unlink($archivePath);
+ $this->log('Local copy removed (remote_keep_local = off)');
+ }
+ } else {
+ $uploadFailed = true;
+ $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
+ $this->log('Local backup is preserved.');
}
- } else {
- $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
+ } catch (\Throwable $e) {
+ $uploadFailed = true;
+ $this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$this->log('Local backup is preserved.');
}
}
@@ -309,9 +319,14 @@ class BackupEngine
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
- // Send success notification
+ // Send success notification (backup completed, even if upload failed)
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
+ // If remote upload failed, also send a failure notification for the upload
+ if ($uploadFailed) {
+ NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
+ }
+
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php
index 4ea16a3..b581e63 100644
--- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php
+++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php
@@ -41,6 +41,10 @@ class SnapshotEngine
private const ARTICLE_RELATED = [
'#__workflow_associations',
'#__contentitem_tag_map',
+ '#__tags',
+ '#__fields',
+ '#__fields_values',
+ '#__fields_categories',
];
/**
@@ -107,6 +111,32 @@ class SnapshotEngine
$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
@@ -231,6 +261,52 @@ class SnapshotEngine
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() ?: [];
+ }
+
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php
index 6bb514f..917d276 100644
--- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php
+++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php
@@ -33,6 +33,10 @@ class SnapshotRestoreEngine
'#__contentitem_tag_map' => null, // composite key, handled specially
'#__modules' => 'id',
'#__modules_menu' => null, // composite key, handled specially
+ '#__tags' => 'id',
+ '#__fields' => 'id',
+ '#__fields_values' => null, // composite key, handled specially
+ '#__fields_categories' => null, // composite key, handled specially
];
/**
@@ -282,6 +286,48 @@ class SnapshotRestoreEngine
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
break;
+ case '#__tags':
+ // Only delete tags that exist in the snapshot — never wipe all tags
+ $ids = array_filter(array_column($rows, 'id'));
+
+ if (empty($ids)) {
+ return;
+ }
+
+ $ids = array_map('intval', $ids);
+ $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
+ break;
+
+ case '#__fields':
+ // Only delete custom fields scoped to com_content.article
+ $query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
+ break;
+
+ case '#__fields_values':
+ // Only delete field values for com_content.article fields
+ $prefix = $db->getPrefix();
+ $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->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
+ break;
+
+ case '#__fields_categories':
+ // Delete field-category mappings for com_content.article fields only
+ $prefix = $db->getPrefix();
+ $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->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
+ break;
+
// #__content and #__content_frontpage are fully owned by com_content
default:
break;
@@ -303,6 +349,10 @@ class SnapshotRestoreEngine
$tables[] = '#__content_frontpage';
$tables[] = '#__workflow_associations';
$tables[] = '#__contentitem_tag_map';
+ $tables[] = '#__tags';
+ $tables[] = '#__fields';
+ $tables[] = '#__fields_values';
+ $tables[] = '#__fields_categories';
}
if (in_array('categories', $types)) {
diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
index 6ba97ea..e6a0f36 100644
--- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
+++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
@@ -389,37 +389,47 @@ class SteppedBackupEngine
private function stepUpload(SteppedSession $session): void
{
$db = Factory::getDbo();
-
- // Reload profile for remote settings
- $query = $db->getQuery(true)
- ->select('*')
- ->from($db->quoteName('#__mokosuitebackup_profiles'))
- ->where($db->quoteName('id') . ' = ' . $session->profileId);
- $db->setQuery($query);
- $profile = $db->loadObject();
-
- $uploader = match ($session->remoteStorage) {
- 'ftp' => new FtpUploader($profile),
- 'google_drive' => new GoogleDriveUploader($profile),
- 's3' => new S3Uploader($profile),
- default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
- };
-
- $session->log('Starting remote upload (' . $session->remoteStorage . ')...');
- $result = $uploader->upload($session->archivePath, $session->archiveName);
-
$remoteFilename = '';
+ $uploadFailed = false;
- if ($result['success']) {
- $remoteFilename = $result['remote_path'] ?? $session->archiveName;
- $session->log('Remote upload complete: ' . $result['message']);
+ // Wrapped in its own try-catch so a remote failure does not mark
+ // the entire backup as failed — the local archive is preserved.
+ try {
+ // Reload profile for remote settings
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitebackup_profiles'))
+ ->where($db->quoteName('id') . ' = ' . $session->profileId);
+ $db->setQuery($query);
+ $profile = $db->loadObject();
- if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
- @unlink($session->archivePath);
- $session->log('Local copy removed');
+ $uploader = match ($session->remoteStorage) {
+ 'ftp' => new FtpUploader($profile),
+ 'google_drive' => new GoogleDriveUploader($profile),
+ 's3' => new S3Uploader($profile),
+ default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
+ };
+
+ $session->log('Starting remote upload (' . $session->remoteStorage . ')...');
+ $result = $uploader->upload($session->archivePath, $session->archiveName);
+
+ if ($result['success']) {
+ $remoteFilename = $result['remote_path'] ?? $session->archiveName;
+ $session->log('Remote upload complete: ' . $result['message']);
+
+ if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
+ @unlink($session->archivePath);
+ $session->log('Local copy removed');
+ }
+ } else {
+ $uploadFailed = true;
+ $session->log('WARNING: Remote upload failed: ' . $result['message']);
+ $session->log('Local backup is preserved.');
}
- } else {
- $session->log('WARNING: Remote upload failed: ' . $result['message']);
+ } catch (\Throwable $e) {
+ $uploadFailed = true;
+ $session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
+ $session->log('Local backup is preserved.');
}
// Update record with remote filename
@@ -433,14 +443,16 @@ class SteppedBackupEngine
$session->currentStep++;
$session->phase = 'complete';
- $session->statusMessage = 'Backup complete';
- $this->completeRecord($session);
+ $session->statusMessage = $uploadFailed
+ ? 'Backup complete (remote upload failed — local archive preserved)'
+ : 'Backup complete';
+ $this->completeRecord($session, $uploadFailed);
}
/**
* Mark the backup record as complete.
*/
- private function completeRecord(SteppedSession $session): void
+ private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void
{
$db = Factory::getDbo();
$logContent = implode("\n", $session->log);
@@ -490,6 +502,11 @@ class SteppedBackupEngine
];
NotificationSender::send($profile, $record, true, $logContent);
+
+ // If remote upload failed, also send a failure notification for the upload
+ if ($uploadFailed) {
+ NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
+ }
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
diff --git a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php
index d21a560..1e7406a 100644
--- a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php
+++ b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php
@@ -136,6 +136,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$session->set('mokosuitebackup.last_cleanup', time());
$this->cleanupOldBackups();
+ $this->cleanupOldSnapshots();
}
/**
@@ -152,6 +153,93 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
}
}
+ /**
+ * Remove old content snapshots per component retention settings.
+ *
+ * Respects snapshot_retention_days (max age) and snapshot_retention_count
+ * (max number to keep). A value of 0 means unlimited for that setting.
+ */
+ private function cleanupOldSnapshots(): void
+ {
+ try {
+ $this->doSnapshotCleanup();
+ } catch (\Throwable $e) {
+ error_log('MokoSuiteBackup: cleanupOldSnapshots() failed: ' . $e->getMessage());
+ }
+ }
+
+ private function doSnapshotCleanup(): void
+ {
+ $db = Factory::getDbo();
+ $params = ComponentHelper::getParams('com_mokosuitebackup');
+ $retentionDays = (int) $params->get('snapshot_retention_days', 30);
+ $retentionCount = (int) $params->get('snapshot_retention_count', 20);
+
+ // Delete snapshots older than retention_days
+ if ($retentionDays > 0) {
+ $cutoff = date('Y-m-d H:i:s', strtotime("-{$retentionDays} days"));
+ $query = $db->getQuery(true)
+ ->select([$db->quoteName('id'), $db->quoteName('data_file')])
+ ->from($db->quoteName('#__mokosuitebackup_snapshots'))
+ ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
+ ->order($db->quoteName('created') . ' DESC');
+ $db->setQuery($query);
+ $expired = $db->loadObjectList();
+
+ foreach ($expired as $snapshot) {
+ $this->deleteSnapshotRecord($db, $snapshot);
+ }
+ }
+
+ // Enforce max count (keep newest)
+ if ($retentionCount > 0) {
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__mokosuitebackup_snapshots'));
+ $db->setQuery($query);
+ $totalCount = (int) $db->loadResult();
+
+ if ($totalCount > $retentionCount) {
+ $excess = $totalCount - $retentionCount;
+ $query = $db->getQuery(true)
+ ->select([$db->quoteName('id'), $db->quoteName('data_file')])
+ ->from($db->quoteName('#__mokosuitebackup_snapshots'))
+ ->order($db->quoteName('created') . ' ASC');
+ $db->setQuery($query, 0, $excess);
+ $oldest = $db->loadObjectList();
+
+ foreach ($oldest as $snapshot) {
+ $this->deleteSnapshotRecord($db, $snapshot);
+ }
+ }
+ }
+ }
+
+ /**
+ * Delete a snapshot record and its JSON data file.
+ */
+ private function deleteSnapshotRecord(object $db, object $snapshot): void
+ {
+ if (!empty($snapshot->data_file) && is_file($snapshot->data_file)) {
+ if (!@unlink($snapshot->data_file)) {
+ error_log('MokoSuiteBackup: Could not delete snapshot file (id=' . $snapshot->id . '): ' . $snapshot->data_file);
+
+ return;
+ }
+ }
+
+ try {
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__mokosuitebackup_snapshots'))
+ ->where($db->quoteName('id') . ' = ' . (int) $snapshot->id)
+ );
+ $db->execute();
+ } catch (\Exception $e) {
+ error_log('MokoSuiteBackup: Could not delete snapshot record ' . $snapshot->id . ': ' . $e->getMessage());
+ }
+ }
+
private function doCleanup(): void
{
$db = Factory::getDbo();