feat: Pre-flight checks before backup starts (#67) #70
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.26.00
|
||||
# VERSION: 01.26.02
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.26.00 -->
|
||||
<!-- VERSION: 01.26.02 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -49,6 +49,13 @@ class BackupsController extends AdminController
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'backend');
|
||||
|
||||
// Surface preflight warnings as Joomla messages
|
||||
if (!empty($result['warnings'])) {
|
||||
foreach ($result['warnings'] as $warning) {
|
||||
$this->app->enqueueMessage($warning, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
|
||||
@@ -32,16 +32,21 @@ class BackupEngine
|
||||
*/
|
||||
public function run(int $profileId, string $description, string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
// Override PHP limits for long-running backup operations
|
||||
$this->overridePhpLimits();
|
||||
|
||||
// Verify required extensions
|
||||
$extCheck = $this->checkRequiredExtensions();
|
||||
|
||||
if ($extCheck !== true) {
|
||||
return ['success' => false, 'message' => $extCheck];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -53,7 +58,12 @@ class BackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Log any preflight warnings
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$this->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
// Read settings directly from profile columns
|
||||
@@ -68,7 +78,7 @@ class BackupEngine
|
||||
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (!BackupDirectory::ensureReady($this->backupDir)) {
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
@@ -300,6 +310,7 @@ class BackupEngine
|
||||
'success' => true,
|
||||
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
|
||||
'record_id' => $recordId,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
@@ -328,7 +339,7 @@ class BackupEngine
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
|
||||
|
||||
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
|
||||
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,35 +394,6 @@ class BackupEngine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify required PHP extensions are loaded.
|
||||
*
|
||||
* @return true|string True if all ok, or error message string
|
||||
*/
|
||||
private function checkRequiredExtensions(): true|string
|
||||
{
|
||||
$required = [
|
||||
'zip' => 'ext-zip (required for archive creation)',
|
||||
'pdo' => 'ext-pdo (required for database operations)',
|
||||
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
|
||||
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $ext => $label) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$missing[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
return 'Missing PHP extensions: ' . implode(', ', $missing);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate archiver based on the archive format.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Pre-flight validation for backup operations.
|
||||
*
|
||||
* Runs before any backup record is created, catching problems early
|
||||
* with clear messages instead of failing mid-backup. Returns a result
|
||||
* with errors (blockers) and warnings (informational).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class PreflightCheck
|
||||
{
|
||||
/** @var string[] Fatal issues that prevent backup from starting */
|
||||
private array $errors = [];
|
||||
|
||||
/** @var string[] Non-fatal issues the user should know about */
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Run all pre-flight checks for a backup profile.
|
||||
*
|
||||
* @param int $profileId Profile to validate
|
||||
*
|
||||
* @return array{pass: bool, errors: string[], warnings: string[]}
|
||||
*/
|
||||
public function run(int $profileId): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
$this->errors[] = 'Profile not found: #' . $profileId;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
if (!$profile->published) {
|
||||
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
$this->checkPhpExtensions($profile);
|
||||
$this->checkBackupDirectory($profile);
|
||||
$this->checkDiskSpace($profile, $db);
|
||||
$this->checkRunningBackup($profile, $db);
|
||||
$this->checkExcludedTables($profile, $db);
|
||||
$this->checkRemoteCredentials($profile);
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that required PHP extensions are loaded.
|
||||
*/
|
||||
private function checkPhpExtensions(object $profile): void
|
||||
{
|
||||
$required = ['pdo', 'pdo_mysql', 'mbstring'];
|
||||
|
||||
// ZIP is required unless using tar.gz
|
||||
$format = $profile->archive_format ?? 'zip';
|
||||
|
||||
if ($format === 'zip') {
|
||||
$required[] = 'zip';
|
||||
}
|
||||
|
||||
foreach ($required as $ext) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
|
||||
}
|
||||
}
|
||||
|
||||
// curl is only needed for remote upload and ntfy notifications
|
||||
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|
||||
|| !empty($profile->ntfy_topic);
|
||||
|
||||
if ($needsCurl && !extension_loaded('curl')) {
|
||||
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the backup directory exists and is writable.
|
||||
*/
|
||||
private function checkBackupDirectory(object $profile): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
|
||||
// Resolve placeholders using a temporary resolver
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
|
||||
// Can't fully validate paths with unresolved placeholders
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($resolvedDir)) {
|
||||
// Try to create it
|
||||
if (!@mkdir($resolvedDir, 0755, true)) {
|
||||
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($resolvedDir)) {
|
||||
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check available disk space against the last backup size + 20% buffer.
|
||||
* Skipped if no previous backup exists for this profile.
|
||||
*/
|
||||
private function checkDiskSpace(object $profile, object $db): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find last successful backup size for this profile
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('total_size'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('total_size') . ' > 0')
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$lastSize = (int) $db->loadResult();
|
||||
|
||||
if ($lastSize === 0) {
|
||||
// No previous backup — skip disk space check
|
||||
return;
|
||||
}
|
||||
|
||||
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
|
||||
$freeBytes = @disk_free_space($resolvedDir);
|
||||
|
||||
if ($freeBytes === false) {
|
||||
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($freeBytes < $requiredBytes) {
|
||||
$freeMB = number_format($freeBytes / 1048576, 1);
|
||||
$neededMB = number_format($requiredBytes / 1048576, 1);
|
||||
|
||||
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
|
||||
. ' (based on last backup + 20% buffer)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if another backup is already running for this profile.
|
||||
*/
|
||||
private function checkRunningBackup(object $profile, object $db): void
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
|
||||
$db->setQuery($query);
|
||||
$running = (int) $db->loadResult();
|
||||
|
||||
if ($running > 0) {
|
||||
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
|
||||
. ' — wait for it to finish or delete the stale record';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that excluded tables actually exist in the database.
|
||||
* Missing tables are warnings, not errors — the profile may have
|
||||
* been copied from another site or a table may have been removed.
|
||||
*/
|
||||
private function checkExcludedTables(object $profile, object $db): void
|
||||
{
|
||||
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
if (empty($excludeRaw)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$allTables = array_flip($db->getTableList());
|
||||
|
||||
foreach ($excludeRaw as $entry) {
|
||||
// Strip :data-only / :structure-only suffixes
|
||||
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
|
||||
|
||||
// Resolve #__ prefix to real prefix
|
||||
$realName = str_replace('#__', $prefix, $tableName);
|
||||
|
||||
if (!isset($allTables[$realName])) {
|
||||
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
|
||||
. ' — it will be silently skipped during backup';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that remote storage credentials are minimally configured.
|
||||
* Does not test the actual connection (too slow for preflight).
|
||||
*/
|
||||
private function checkRemoteCredentials(object $profile): void
|
||||
{
|
||||
$remote = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remote === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($remote) {
|
||||
case 'ftp':
|
||||
if (empty($profile->ftp_host)) {
|
||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->ftp_username)) {
|
||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
if (empty($profile->s3_bucket)) {
|
||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'google_drive':
|
||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->gdrive_refresh_token)) {
|
||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the result array.
|
||||
*/
|
||||
private function result(): array
|
||||
{
|
||||
return [
|
||||
'pass' => empty($this->errors),
|
||||
'errors' => $this->errors,
|
||||
'warnings' => $this->warnings,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,18 @@ class SteppedBackupEngine
|
||||
*/
|
||||
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -43,7 +55,7 @@ class SteppedBackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Create session
|
||||
@@ -130,6 +142,11 @@ class SteppedBackupEngine
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
// Log any preflight warnings into the session
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
$session->statusMessage = 'Initialized — starting backup...';
|
||||
$session->save();
|
||||
|
||||
@@ -138,6 +155,7 @@ class SteppedBackupEngine
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
|
||||
@@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.26.00</version>
|
||||
<version>01.26.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user