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'))