feat: dashboard widget (#18), differential backups (#19), JPA import (#20)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled

#18 — Dashboard Quickicon Widget:
- plg_quickicon_mokobackup: shows backup status on admin dashboard
- Displays: last backup time, total count, storage used
- Warning states: no backups, recent failures, stale (>7 days)
- Links to backup records view

#19 — Differential Backups:
- New backup_type: "differential" — only backs up changed/new files
- DifferentialScanner: builds manifest (path+size+mtime) on full backups
- Compares current filesystem against base manifest on differential runs
- Manifest stored in backup record (LONGTEXT column)
- Falls back to full backup if no base manifest exists
- Database is always fully dumped (no incremental DB)

#20 — JPA Format Import:
- JpaUnarchiver: parses Akeeba JPA binary format
- Handles: archive header, file entities, gzip decompression, permissions
- RestoreEngine auto-detects JPA vs ZIP by reading signature bytes
- Enables restoring from existing Akeeba .jpa backup files

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 18:55:12 -05:00
parent a928362508
commit 63997252bf
25 changed files with 659 additions and 6 deletions
+8
View File
@@ -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)
@@ -26,6 +26,7 @@
<option value="full">COM_MOKOBACKUP_TYPE_FULL</option>
<option value="database">COM_MOKOBACKUP_TYPE_DATABASE</option>
<option value="files">COM_MOKOBACKUP_TYPE_FILES</option>
<option value="differential">COM_MOKOBACKUP_TYPE_DIFFERENTIAL</option>
</field>
</fieldset>
@@ -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"
@@ -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`),
@@ -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.
*
@@ -0,0 +1,91 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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<string, array{size: int, mtime: int}>
*/
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;
}
}
@@ -0,0 +1,267 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
}
@@ -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
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -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"
@@ -0,0 +1,2 @@
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -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"
@@ -0,0 +1,2 @@
PLG_QUICKICON_MOKOBACKUP="Quick Icon - MokoJoomBackup"
PLG_QUICKICON_MOKOBACKUP_DESCRIPTION="Shows backup status on the administrator dashboard."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,3 @@
<?php
defined('_JEXEC') or die;
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>plg_quickicon_mokobackup</name>
<version>01.00.00-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_QUICKICON_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Quickicon\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_quickicon_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_quickicon_mokobackup.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,29 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Quickicon\MokoBackup\Extension\MokoBackupQuickicon;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupQuickicon(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('quickicon', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,133 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_quickicon_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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 ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '',
'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';
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
+1
View File
@@ -23,6 +23,7 @@
<file type="component" id="com_mokobackup">com_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="system">plg_system_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="task">plg_task_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="quickicon">plg_quickicon_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="webservices">plg_webservices_mokobackup.zip</file>
</files>
+11
View File
@@ -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'))