diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6a1bf..04f11f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ - "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip - FileRestorer class with protected file handling (preserves configuration.php, .htaccess) - DatabaseImporter with streaming line-by-line SQL execution and error tolerance +- Admin dashboard quickicon widget — backup status at a glance with warnings (#18) +- Differential backups — only back up files changed since last full backup (#19) +- DifferentialScanner: builds file manifests (path/size/mtime) and compares against base +- File manifest stored in backup record for future differential comparisons +- Automatic full-backup fallback when no base manifest exists +- JPA archive format import for Akeeba Backup migration (#20) +- JpaUnarchiver: parses Akeeba JPA binary format (headers, gzip, permissions) +- RestoreEngine auto-detects JPA vs ZIP format - AES-256 archive encryption with per-profile password (#17) - Encrypted archive support in RestoreEngine (password parameter) - Encrypted archive support in Kickstart restore.php (password field in UI) diff --git a/src/packages/com_mokobackup/forms/profile.xml b/src/packages/com_mokobackup/forms/profile.xml index 91b34d5..f712686 100644 --- a/src/packages/com_mokobackup/forms/profile.xml +++ b/src/packages/com_mokobackup/forms/profile.xml @@ -26,6 +26,7 @@ + diff --git a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini index 4f6642f..468be17 100644 --- a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini +++ b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini @@ -122,6 +122,7 @@ COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC="Google Drive folder ID where backups COM_MOKOBACKUP_TYPE_FULL="Full Site (Database + Files)" COM_MOKOBACKUP_TYPE_DATABASE="Database Only" COM_MOKOBACKUP_TYPE_FILES="Files Only" +COM_MOKOBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)" ; Status labels COM_MOKOBACKUP_STATUS_COMPLETE="Complete" diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/src/packages/com_mokobackup/sql/install.mysql.sql index 0655a02..fe7c580 100644 --- a/src/packages/com_mokobackup/sql/install.mysql.sql +++ b/src/packages/com_mokobackup/sql/install.mysql.sql @@ -62,6 +62,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( `filesexist` TINYINT(1) NOT NULL DEFAULT 1, `remote_filename` VARCHAR(512) NOT NULL DEFAULT '', `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive', + `base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential', + `manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison', `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', PRIMARY KEY (`id`), KEY `idx_profile` (`profile_id`), diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index ad20f76..923c6d4 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -127,14 +127,32 @@ class BackupEngine } // Step 2: Files (unless database-only) + $manifest = []; + if ($profile->backup_type !== 'database') { $this->log('Starting file scan...'); - $scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles); - $files = $scanner->scan(); - $filesCount = count($files); - $this->log('Found ' . $filesCount . ' files to back up'); + $scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles); + $allFiles = $scanner->scan(); - foreach ($files as $relativePath) { + // Differential: only include changed files + if ($profile->backup_type === 'differential') { + $baseManifest = $this->loadBaseManifest($db, $profileId); + + if (empty($baseManifest)) { + $this->log('No base full backup found — running full backup instead'); + $filesToBackup = $allFiles; + } else { + $filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT); + $this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total'); + } + } else { + $filesToBackup = $allFiles; + } + + $filesCount = count($filesToBackup); + $this->log('Backing up ' . $filesCount . ' files'); + + foreach ($filesToBackup as $relativePath) { $fullPath = JPATH_ROOT . '/' . $relativePath; if (is_file($fullPath) && is_readable($fullPath)) { @@ -143,6 +161,12 @@ class BackupEngine } $this->log('Files added to archive'); + + // Build manifest for full/differential backups (used by future differentials) + if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) { + $manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT); + $this->log('File manifest built: ' . count($manifest) . ' entries'); + } } $zip->close(); @@ -217,6 +241,7 @@ class BackupEngine 'filesexist' => is_file($archivePath) ? 1 : 0, 'remote_filename' => $remoteFilename, 'checksum' => $checksum, + 'manifest' => !empty($manifest) ? json_encode($manifest) : '', 'log' => implode("\n", $this->log), ]; @@ -342,6 +367,30 @@ class BackupEngine }; } + /** + * Load the file manifest from the most recent full backup for this profile. + * Used by differential backups to determine which files changed. + */ + private function loadBaseManifest(object $db, int $profileId): array + { + $query = $db->getQuery(true) + ->select($db->quoteName('manifest')) + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('profile_id') . ' = ' . $profileId) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ->where($db->quoteName('manifest') . ' != ' . $db->quote('')) + ->where($db->quoteName('backup_type') . ' = ' . $db->quote('full')) + ->order($db->quoteName('backupstart') . ' DESC'); + $db->setQuery($query, 0, 1); + $manifestJson = $db->loadResult(); + + if (empty($manifestJson)) { + return []; + } + + return json_decode($manifestJson, true) ?: []; + } + /** * Encrypt a ZIP archive using AES-256. * diff --git a/src/packages/com_mokobackup/src/Engine/DifferentialScanner.php b/src/packages/com_mokobackup/src/Engine/DifferentialScanner.php new file mode 100644 index 0000000..ce477f5 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/DifferentialScanner.php @@ -0,0 +1,91 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Differential file scanner — compares current filesystem against a + * stored manifest from the last full backup. Only returns files that + * are new or modified since the base backup. + * + * Manifest format (JSON): + * {"path/to/file": {"size": 1234, "mtime": 1717350000}, ...} + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class DifferentialScanner +{ + /** + * Build a file manifest for the current state of the site. + * + * @param string[] $filePaths Array of relative file paths (from FileScanner) + * @param string $rootDir Joomla root directory + * + * @return array + */ + public static function buildManifest(array $filePaths, string $rootDir): array + { + $manifest = []; + + foreach ($filePaths as $relativePath) { + $fullPath = rtrim($rootDir, '/') . '/' . $relativePath; + + if (!is_file($fullPath)) { + continue; + } + + $manifest[$relativePath] = [ + 'size' => (int) filesize($fullPath), + 'mtime' => (int) filemtime($fullPath), + ]; + } + + return $manifest; + } + + /** + * Compare current files against a base manifest and return only changed/new files. + * + * @param array $currentFiles Array of relative file paths from FileScanner + * @param array $baseManifest Manifest from the base full backup + * @param string $rootDir Joomla root directory + * + * @return string[] Array of relative paths that are new or modified + */ + public static function getChangedFiles(array $currentFiles, array $baseManifest, string $rootDir): array + { + $changed = []; + + foreach ($currentFiles as $relativePath) { + $fullPath = rtrim($rootDir, '/') . '/' . $relativePath; + + if (!is_file($fullPath)) { + continue; + } + + // New file — not in base manifest + if (!isset($baseManifest[$relativePath])) { + $changed[] = $relativePath; + + continue; + } + + // Check if modified (size or mtime changed) + $currentSize = (int) filesize($fullPath); + $currentMtime = (int) filemtime($fullPath); + $baseEntry = $baseManifest[$relativePath]; + + if ($currentSize !== $baseEntry['size'] || $currentMtime !== $baseEntry['mtime']) { + $changed[] = $relativePath; + } + } + + return $changed; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php b/src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php new file mode 100644 index 0000000..e732237 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php @@ -0,0 +1,267 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files. + * + * JPA Format Structure: + * - Header: signature (3 bytes "JPA"), header length, major/minor version + * - Entity headers: signature (3 bytes), header length, path length, path, + * compression type (0=none, 1=gzip), compressed size, uncompressed size, + * permissions, then compressed data + * + * Read-only: extracts JPA archives to a staging directory. + * The RestoreEngine can then restore from the extracted files. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class JpaUnarchiver +{ + private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA" + private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity + + private string $archivePath; + private string $outputDir; + private int $filesExtracted = 0; + + public function __construct(string $archivePath, string $outputDir) + { + $this->archivePath = $archivePath; + $this->outputDir = rtrim($outputDir, '/\\'); + } + + /** + * Extract a JPA archive to the output directory. + * + * @return int Number of files extracted + * + * @throws \RuntimeException On format errors or extraction failure + */ + public function extract(): int + { + if (!is_file($this->archivePath) || !is_readable($this->archivePath)) { + throw new \RuntimeException('JPA file not readable: ' . $this->archivePath); + } + + $handle = fopen($this->archivePath, 'rb'); + + if ($handle === false) { + throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath); + } + + try { + // Read and validate archive header + $this->readArchiveHeader($handle); + + // Read entities until EOF + while (!feof($handle)) { + $pos = ftell($handle); + + // Try to read entity signature + $sig = fread($handle, 3); + + if ($sig === false || strlen($sig) < 3) { + break; // End of archive + } + + if ($sig === self::ENTITY_SIGNATURE) { + $this->readFileEntity($handle); + } else { + // Unknown entity — try to skip by reading header length + fseek($handle, $pos + 3); + $headerLenData = fread($handle, 2); + + if ($headerLenData === false || strlen($headerLenData) < 2) { + break; + } + + $headerLen = unpack('v', $headerLenData)[1]; + // Skip remaining header + data + fseek($handle, $pos + 3 + $headerLen); + } + } + + return $this->filesExtracted; + } finally { + fclose($handle); + } + } + + /** + * Read and validate the JPA archive header. + */ + private function readArchiveHeader($handle): void + { + $signature = fread($handle, 3); + + if ($signature !== self::JPA_SIGNATURE) { + throw new \RuntimeException('Not a valid JPA archive — invalid signature'); + } + + // Header length (2 bytes, little-endian) + $headerLenData = fread($handle, 2); + + if ($headerLenData === false || strlen($headerLenData) < 2) { + throw new \RuntimeException('Truncated JPA header'); + } + + $headerLen = unpack('v', $headerLenData)[1]; + + // Version: major (1 byte), minor (1 byte) + $versionData = fread($handle, 2); + + if ($versionData === false || strlen($versionData) < 2) { + throw new \RuntimeException('Cannot read JPA version'); + } + + // File count (4 bytes, little-endian) + $countData = fread($handle, 4); + + if ($countData === false || strlen($countData) < 4) { + throw new \RuntimeException('Cannot read file count'); + } + + // Skip any remaining header bytes + $bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count + + if ($headerLen > ($bytesRead - 3)) { + $remaining = $headerLen - ($bytesRead - 3); + + if ($remaining > 0) { + fseek($handle, ftell($handle) + $remaining); + } + } + } + + /** + * Read a single file entity and extract it. + */ + private function readFileEntity($handle): void + { + // Entity header length (2 bytes) + $headerLenData = fread($handle, 2); + + if ($headerLenData === false || strlen($headerLenData) < 2) { + return; + } + + $headerLen = unpack('v', $headerLenData)[1]; + + // Path length (2 bytes) + $pathLenData = fread($handle, 2); + + if ($pathLenData === false || strlen($pathLenData) < 2) { + return; + } + + $pathLen = unpack('v', $pathLenData)[1]; + + // Path (variable) + $path = fread($handle, $pathLen); + + if ($path === false || strlen($path) < $pathLen) { + return; + } + + // Compression type (1 byte): 0 = none, 1 = gzip + $compTypeData = fread($handle, 1); + $compType = ord($compTypeData); + + // Compressed size (4 bytes) + $compSizeData = fread($handle, 4); + $compSize = unpack('V', $compSizeData)[1]; + + // Uncompressed size (4 bytes) + $uncompSizeData = fread($handle, 4); + $uncompSize = unpack('V', $uncompSizeData)[1]; + + // Permissions (4 bytes) + $permsData = fread($handle, 4); + $perms = unpack('V', $permsData)[1]; + + // Skip any remaining header bytes + $entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4; + $entityHeaderTotal = $headerLen; + + if ($entityHeaderTotal > $entityHeaderRead) { + fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead)); + } + + // Read compressed data + $data = ''; + + if ($compSize > 0) { + $data = fread($handle, $compSize); + + if ($data === false || strlen($data) < $compSize) { + return; + } + } + + // Is this a directory? + if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) { + $dirPath = $this->outputDir . '/' . $path; + + if (!is_dir($dirPath)) { + mkdir($dirPath, 0755, true); + } + + return; + } + + // Decompress if needed + if ($compType === 1 && !empty($data)) { + $data = @gzinflate($data); + + if ($data === false) { + throw new \RuntimeException('Failed to decompress file: ' . $path); + } + } + + // Write file + $fullPath = $this->outputDir . '/' . $path; + $parentDir = dirname($fullPath); + + if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + + file_put_contents($fullPath, $data); + + // Set permissions (only if reasonable) + if ($perms > 0 && $perms <= 0777) { + @chmod($fullPath, $perms); + } + + $this->filesExtracted++; + } + + /** + * Check if a file appears to be a JPA archive. + */ + public static function isJpaFile(string $path): bool + { + if (!is_file($path) || !is_readable($path)) { + return false; + } + + $handle = fopen($path, 'rb'); + + if ($handle === false) { + return false; + } + + $sig = fread($handle, 3); + fclose($handle); + + return $sig === self::JPA_SIGNATURE; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php index 6810445..eb33467 100644 --- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php +++ b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php @@ -88,7 +88,16 @@ class RestoreEngine try { // Step 1: Extract archive to staging $this->log('Extracting archive: ' . basename($archivePath)); - $this->extractArchive($archivePath, $password); + + // Detect format: JPA or ZIP + if (JpaUnarchiver::isJpaFile($archivePath)) { + $this->log('Detected JPA format (Akeeba Backup archive)'); + $jpa = new JpaUnarchiver($archivePath, $this->stagingDir); + $count = $jpa->extract(); + $this->log('Extracted ' . $count . ' files from JPA'); + } else { + $this->extractArchive($archivePath, $password); + } $this->log('Extraction complete'); // Step 2: Preserve configuration.php diff --git a/src/packages/plg_quickicon_mokobackup/index.html b/src/packages/plg_quickicon_mokobackup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/language/en-GB/index.html b/src/packages/plg_quickicon_mokobackup/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.ini b/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.ini new file mode 100644 index 0000000..7dbf949 --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.ini @@ -0,0 +1,6 @@ +PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup" +PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard." +PLG_QUICKICON_MOKOBACKUP_OK="Backups: OK" +PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS="Backups: No backups yet!" +PLG_QUICKICON_MOKOBACKUP_FAILURES="Backups: Recent failures!" +PLG_QUICKICON_MOKOBACKUP_STALE="Backups: Last backup > 7 days ago" diff --git a/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.sys.ini b/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.sys.ini new file mode 100644 index 0000000..6d06042 --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-GB/plg_quickicon_mokobackup.sys.ini @@ -0,0 +1,2 @@ +PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup" +PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard." diff --git a/src/packages/plg_quickicon_mokobackup/language/en-US/index.html b/src/packages/plg_quickicon_mokobackup/language/en-US/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-US/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.ini b/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.ini new file mode 100644 index 0000000..7dbf949 --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.ini @@ -0,0 +1,6 @@ +PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup" +PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard." +PLG_QUICKICON_MOKOBACKUP_OK="Backups: OK" +PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS="Backups: No backups yet!" +PLG_QUICKICON_MOKOBACKUP_FAILURES="Backups: Recent failures!" +PLG_QUICKICON_MOKOBACKUP_STALE="Backups: Last backup > 7 days ago" diff --git a/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.sys.ini b/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.sys.ini new file mode 100644 index 0000000..6d06042 --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/en-US/plg_quickicon_mokobackup.sys.ini @@ -0,0 +1,2 @@ +PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup" +PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard." diff --git a/src/packages/plg_quickicon_mokobackup/language/index.html b/src/packages/plg_quickicon_mokobackup/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/mokobackup.php b/src/packages/plg_quickicon_mokobackup/mokobackup.php new file mode 100644 index 0000000..542f639 --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/mokobackup.php @@ -0,0 +1,3 @@ + + + plg_quickicon_mokobackup + 01.00.00-dev + 2026-06-02 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_QUICKICON_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\Quickicon\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_quickicon_mokobackup.ini + language/en-GB/plg_quickicon_mokobackup.sys.ini + + diff --git a/src/packages/plg_quickicon_mokobackup/services/index.html b/src/packages/plg_quickicon_mokobackup/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/services/provider.php b/src/packages/plg_quickicon_mokobackup/services/provider.php new file mode 100644 index 0000000..32262ef --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/services/provider.php @@ -0,0 +1,29 @@ +set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackupQuickicon( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('quickicon', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php new file mode 100644 index 0000000..5d0bd9a --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/src/Extension/MokoBackupQuickicon.php @@ -0,0 +1,133 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Dashboard quickicon widget showing backup status at a glance. + */ + +namespace Joomla\Plugin\Quickicon\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; + +final class MokoBackupQuickicon extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onGetIcons' => 'onGetIcons', + ]; + } + + public function onGetIcons(Event $event): void + { + $context = $event->getArgument('context', $event->getArgument(0, '')); + + if ($context !== 'mod_quickicon' && $context !== 'update_quickicon') { + return; + } + + $db = Factory::getDbo(); + + // Get last completed backup + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ->order($db->quoteName('backupstart') . ' DESC'); + $db->setQuery($query, 0, 1); + $lastBackup = $db->loadObject(); + + // Get total count and storage + $query = $db->getQuery(true) + ->select('COUNT(*) AS total, COALESCE(SUM(total_size), 0) AS total_size') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $stats = $db->loadObject(); + + // Check for recent failures + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokobackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('fail')) + ->where($db->quoteName('backupstart') . ' > DATE_SUB(NOW(), INTERVAL 7 DAY)'); + $db->setQuery($query); + $recentFailures = (int) $db->loadResult(); + + // Determine icon state + $warning = false; + $text = 'PLG_QUICKICON_MOKOBACKUP_OK'; + + if (!$lastBackup) { + $warning = true; + $text = 'PLG_QUICKICON_MOKOBACKUP_NO_BACKUPS'; + } elseif ($recentFailures > 0) { + $warning = true; + $text = 'PLG_QUICKICON_MOKOBACKUP_FAILURES'; + } elseif (strtotime($lastBackup->backupstart) < strtotime('-7 days')) { + $warning = true; + $text = 'PLG_QUICKICON_MOKOBACKUP_STALE'; + } + + // Build subtitle + $subtitle = ''; + + if ($lastBackup) { + $ago = $this->timeAgo($lastBackup->backupstart); + $sizeTotal = number_format(($stats->total_size ?? 0) / 1048576, 1); + $subtitle = $ago . ' | ' . ($stats->total ?? 0) . ' backups | ' . $sizeTotal . ' MB'; + } + + $result = $event->getArgument('result', []); + $result[] = [ + [ + 'link' => 'index.php?option=com_mokobackup&view=backups', + 'image' => $warning ? 'icon-warning' : 'icon-database', + 'icon' => $warning ? 'icon-warning' : 'icon-database', + 'text' => $text, + 'linkadd' => $subtitle ? '
' . htmlspecialchars($subtitle) . '' : '', + 'id' => 'plg_quickicon_mokobackup', + 'group' => 'MOD_QUICKICON_MAINTENANCE', + ], + ]; + + $event->setArgument('result', $result); + } + + private function timeAgo(string $datetime): string + { + $diff = time() - strtotime($datetime); + + if ($diff < 60) { + return 'just now'; + } + + if ($diff < 3600) { + $m = (int) ($diff / 60); + + return $m . ' min ago'; + } + + if ($diff < 86400) { + $h = (int) ($diff / 3600); + + return $h . 'h ago'; + } + + $d = (int) ($diff / 86400); + + return $d . 'd ago'; + } +} diff --git a/src/packages/plg_quickicon_mokobackup/src/Extension/index.html b/src/packages/plg_quickicon_mokobackup/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_quickicon_mokobackup/src/index.html b/src/packages/plg_quickicon_mokobackup/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_quickicon_mokobackup/src/index.html @@ -0,0 +1 @@ + diff --git a/src/pkg_mokobackup.xml b/src/pkg_mokobackup.xml index 74684ed..7a02742 100644 --- a/src/pkg_mokobackup.xml +++ b/src/pkg_mokobackup.xml @@ -23,6 +23,7 @@ com_mokobackup.zip plg_system_mokobackup.zip plg_task_mokobackup.zip + plg_quickicon_mokobackup.zip plg_webservices_mokobackup.zip diff --git a/src/script.php b/src/script.php index a7b2c88..ed0db68 100644 --- a/src/script.php +++ b/src/script.php @@ -74,6 +74,17 @@ class Pkg_MokoBackupInstallerScript $db->setQuery($query); $db->execute(); + // Enable the quickicon plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('quickicon')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + // Enable the task plugin automatically $query = $db->getQuery(true) ->update($db->quoteName('#__extensions'))