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();