Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/tmpl/backups/default.php
T
jmiller 10248b284a
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 12s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 4m0s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
fix: enforce per-profile retention and repair Purge Old Backups button
Two related backup-management fixes.

Retention (records/days to keep):
- The retention fieldset was defined in profile.xml but never rendered
  in the profile editor, so retention_days/retention_count were invisible.
  Render the retention fieldset on the Archive tab.
- retention_days/retention_count were read by nothing, so they pruned no
  backups. Add RetentionManager::prune(), called from completeRecord() in
  both BackupEngine and SteppedBackupEngine after a backup finishes.
  Policy: delete a completed/warning backup when EITHER it is older than
  retention_days OR it falls outside the newest retention_count copies
  (0 = unlimited for that rule). Deleting a record also removes its
  archive and log file.
- Correct misleading language/schema text ("use global default" — no such
  global backup-retention setting exists) to "0 = unlimited".

Purge Old Backups button:
- The modal only opened via a fragile selector match on the toolbar
  button's inline onclick, which Joomla 6's Atum toolbar does not render,
  so the button did nothing. Wrap Joomla.submitbutton to open the modal
  for the backups.purgeModal task, keeping the selector as a fallback.
2026-07-04 15:25:43 -05:00

803 lines
29 KiB
PHP

<?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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.multiselect');
$user = Factory::getApplication()->getIdentity();
$canDownload = $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup');
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_NO_BACKUPS'); ?>
</div>
<?php else : ?>
<table class="table" id="backupList">
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION'); ?></caption>
<thead>
<tr>
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.checkall'); ?>
</td>
<th scope="col">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_PROFILE', 'a.profile_id', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_STATUS', 'a.status', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_SIZE', 'a.total_size', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-5">
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) : ?>
<tr>
<td class="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
</td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backup&id=' . $item->id); ?>">
<?php echo $this->escape($item->description); ?>
</a>
<?php if (!empty($item->checksum)) : ?>
<br><small class="text-muted font-monospace"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_CHECKSUM'); ?>: <?php echo substr($item->checksum, 0, 16); ?>...</small>
<?php endif; ?>
</td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=profile.edit&id=' . (int) $item->profile_id); ?>">
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
</a>
</td>
<td>
<?php
$statusClass = match ($item->status) {
'complete' => 'badge bg-success',
'warning' => 'badge bg-warning text-dark',
'running' => 'badge bg-info',
'fail' => 'badge bg-danger',
default => 'badge bg-secondary',
};
?>
<span class="<?php echo $statusClass; ?>"><?php echo $this->escape($item->status); ?></span>
</td>
<td>
<?php echo $this->escape($item->backup_type); ?>
</td>
<td>
<?php
if ($item->total_size > 0) {
echo HTMLHelper::_('number.bytes', $item->total_size);
} else {
echo '—';
}
?>
</td>
<td>
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
</td>
<td>
<?php echo (int) $item->id; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</div>
</div>
</form>
<!-- Stepped Backup Modal (for shared hosting) -->
<div class="modal fade" id="mokosuitebackup-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mb-modal-title">Backup in Progress</h5>
</div>
<div class="modal-body">
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div class="progress mb-2" style="height:24px;">
<div id="mb-progress-bar" class="progress-bar" role="progressbar" style="width:0%;">0%</div>
</div>
<p id="mb-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
<p id="mb-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
</div>
</div>
</div>
</div>
<script>
(function() {
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
var backupRunning = false;
function warnBeforeClose(e) {
if (backupRunning) {
e.preventDefault();
e.returnValue = '';
}
}
window.addEventListener('beforeunload', warnBeforeClose);
function showModal() {
backupRunning = true;
bootstrap.Modal.getOrCreateInstance(document.getElementById('mokosuitebackup-modal')).show();
}
function hideModal() {
backupRunning = false;
bootstrap.Modal.getInstance(document.getElementById('mokosuitebackup-modal'))?.hide();
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
const form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (const [k, v] of Object.entries(params)) {
form.append(k, v);
}
const res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startSteppedBackup() {
const profileSelect = document.getElementById('mb-profile-select');
const profileId = profileSelect ? profileSelect.value : '1';
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
// Init
const initResult = await postAjax({
task: 'ajax.init',
profile_id: profileId
});
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
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);
// Run steps until done
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
// Complete
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
// Expose for toolbar button
window.mokosuitebackupStart = startSteppedBackup;
// Intercept Restore toolbar button to show the modal
document.addEventListener('DOMContentLoaded', function() {
var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload');
if (restoreBtn) {
restoreBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Get selected record from checkboxes
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
if (checked.length === 0) {
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', true); ?>');
return false;
}
document.getElementById('mb-restore-record-id').value = checked[0].value;
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-modal')).show();
return false;
}, true);
}
});
// Close restore modal handled by Bootstrap data-bs-dismiss
// AJAX stepped restore
var restoreRunning = false;
function showRestoreProgress() {
restoreRunning = true;
bootstrap.Modal.getInstance(document.getElementById('mb-restore-modal'))?.hide();
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-restore-progress-modal')).show();
}
function hideRestoreProgress() {
restoreRunning = false;
bootstrap.Modal.getInstance(document.getElementById('mb-restore-progress-modal'))?.hide();
}
function updateRestoreProgress(progress, message, phase) {
var bar = document.getElementById('mb-restore-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-restore-status').textContent = message;
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
}
window.addEventListener('beforeunload', function(e) {
if (restoreRunning) {
e.preventDefault();
e.returnValue = '';
}
});
async function startSteppedRestore(e) {
e.preventDefault();
var recordId = document.getElementById('mb-restore-record-id').value;
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
var password = document.getElementById('mb-restore-password').value;
showRestoreProgress();
updateRestoreProgress(0, 'Initializing restore...', 'init');
try {
var initResult = await postAjax({
task: 'ajax.restoreInit',
id: recordId,
restore_files: restoreFiles,
restore_db: restoreDb,
preserve_config: preserveConfig,
encryption_password: password
});
if (initResult.error) {
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
var sessionId = initResult.session_id;
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({
task: 'ajax.restoreStep',
session_id: sessionId
});
if (stepResult.error) {
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
setTimeout(function() {
hideRestoreProgress();
location.reload();
}, 2000);
} catch (err) {
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
}
}
// Attach the AJAX restore handler to the restore form
document.addEventListener('DOMContentLoaded', function() {
var restoreForm = document.getElementById('mb-restore-form');
if (restoreForm) {
restoreForm.addEventListener('submit', startSteppedRestore);
}
});
})();
</script>
<!-- Restore Confirmation Modal -->
<div class="modal fade" id="mb-restore-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
<input type="hidden" name="id" id="mb-restore-record-id" value="">
<div class="modal-body">
<div class="alert alert-danger">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
<label class="form-check-label" for="mb-restore-files">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
<label class="form-check-label" for="mb-restore-db">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
<label class="form-check-label" for="mb-restore-config">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
</label>
</div>
</div>
<div class="mb-3">
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<!-- Restore Progress Modal -->
<div class="modal fade" id="mb-restore-progress-modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mb-restore-title">Restore in Progress</h5>
</div>
<div class="modal-body">
<div class="progress mb-2" style="height:24px;">
<div id="mb-restore-progress-bar" class="progress-bar bg-danger" role="progressbar" style="width:0%;">0%</div>
</div>
<p id="mb-restore-status" class="text-muted mb-1" style="font-size:0.9rem;">Initializing...</p>
<p id="mb-restore-phase" class="text-muted mb-0" style="font-size:0.8rem;">Phase: init</p>
</div>
</div>
</div>
</div>
<!-- Purge Backups Modal -->
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
<?php if ($canDelete) : ?>
<div class="modal fade" id="mb-purge-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
<div class="modal-body">
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
<div class="mb-3">
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
</div>
<div id="mb-purge-count-wrapper" style="display:none;">
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
</div>
<div id="mb-purge-none-wrapper" style="display:none;">
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
</div>
<?php endif; ?>
<!-- Backup Comparison Modal -->
<div class="modal fade" id="mb-compare-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<span class="icon-copy" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
<div id="mb-compare-loading" class="text-center py-4">
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
</div>
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
<table id="mb-compare-table" class="table table-striped" style="display:none;">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
</tr>
</thead>
<tbody id="mb-compare-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(function() {
var COMPARE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var COMPARE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
function mbCmpFormatBytes(bytes) {
if (bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
}
function mbCmpFormatDuration(seconds) {
if (seconds <= 0) return '0s';
var m = Math.floor(seconds / 60);
var s = seconds % 60;
return m > 0 ? m + 'm ' + s + 's' : s + 's';
}
function mbCmpDeltaCell(value, unit) {
if (value === 0) return '<span class="text-muted">&mdash;</span>';
var isPositive = value > 0;
var colorClass = isPositive ? 'text-danger' : 'text-success';
var display;
if (unit === 'bytes') {
display = (isPositive ? '+' : '') + mbCmpFormatBytes(value);
} else if (unit === 'duration') {
display = (isPositive ? '+' : '-') + mbCmpFormatDuration(Math.abs(value));
} else {
display = (isPositive ? '+' : '') + value.toLocaleString();
}
return '<span class="fw-bold ' + colorClass + '">' + display + '</span>';
}
function mbShowCompareModal(id1, id2) {
var modal = document.getElementById('mb-compare-modal');
var loading = document.getElementById('mb-compare-loading');
var errorEl = document.getElementById('mb-compare-error');
var table = document.getElementById('mb-compare-table');
var body = document.getElementById('mb-compare-body');
bootstrap.Modal.getOrCreateInstance(modal).show();
loading.style.display = 'block';
errorEl.style.display = 'none';
table.style.display = 'none';
body.innerHTML = '';
var form = new URLSearchParams();
form.append('task', 'ajax.compareBackups');
form.append('id1', id1);
form.append('id2', id2);
form.append(COMPARE_TOKEN, '1');
fetch(COMPARE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
loading.style.display = 'none';
if (data.error) {
errorEl.textContent = data.message || 'Error loading comparison';
errorEl.style.display = 'block';
return;
}
var b1 = data.backup1;
var b2 = data.backup2;
var d = data.delta;
var dur1 = 0, dur2 = 0;
if (b1.backupstart !== '0000-00-00 00:00:00' && b1.backupend !== '0000-00-00 00:00:00') {
dur1 = (new Date(b1.backupend).getTime() - new Date(b1.backupstart).getTime()) / 1000;
}
if (b2.backupstart !== '0000-00-00 00:00:00' && b2.backupend !== '0000-00-00 00:00:00') {
dur2 = (new Date(b2.backupend).getTime() - new Date(b2.backupstart).getTime()) / 1000;
}
var rows = [
['<?php echo Text::_('JGRID_HEADING_ID', true); ?>', '#' + b1.id, '#' + b2.id, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', true); ?>', b1.description || '&mdash;', b2.description || '&mdash;', ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '&mdash;', b2.profile_title || '&mdash;', ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS', true); ?>', b1.status, b2.status, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE', true); ?>', b1.backup_type, b2.backup_type, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_SIZE', true); ?>', mbCmpFormatBytes(b1.total_size), mbCmpFormatBytes(b2.total_size), mbCmpDeltaCell(d.size_diff, 'bytes')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE', true); ?>', mbCmpFormatBytes(b1.db_size), mbCmpFormatBytes(b2.db_size), mbCmpDeltaCell(b2.db_size - b1.db_size, 'bytes')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT', true); ?>', b1.files_count.toLocaleString(), b2.files_count.toLocaleString(), mbCmpDeltaCell(d.files_diff, 'number')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT', true); ?>', b1.tables_count.toLocaleString(), b2.tables_count.toLocaleString(), mbCmpDeltaCell(d.tables_diff, 'number')],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE', true); ?>', b1.backupstart, b2.backupstart, ''],
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DURATION', true); ?>', mbCmpFormatDuration(dur1), mbCmpFormatDuration(dur2), mbCmpDeltaCell(d.duration_diff_seconds, 'duration')],
];
var html = '';
for (var i = 0; i < rows.length; i++) {
html += '<tr><td class="fw-bold">' + rows[i][0] + '</td><td>' + rows[i][1] + '</td><td>' + rows[i][2] + '</td><td>' + rows[i][3] + '</td></tr>';
}
body.innerHTML = html;
table.style.display = 'table';
})
.catch(function(err) {
loading.style.display = 'none';
errorEl.textContent = 'Error: ' + err.message;
errorEl.style.display = 'block';
});
}
// Compare modal close handled by Bootstrap data-bs-dismiss
// Intercept Compare toolbar button
document.addEventListener('DOMContentLoaded', function() {
var compareBtn = document.querySelector('[onclick*="backups.compare"], .button-copy');
if (compareBtn) {
compareBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
if (checked.length !== 2) {
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO', true); ?>');
return false;
}
mbShowCompareModal(checked[0].value, checked[1].value);
return false;
}, true);
}
});
})();
</script>
<?php if ($canDelete) : ?>
<script>
(function() {
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
var purgeCountTimer = null;
// Reset modal state and show it.
function openPurgeModal() {
document.getElementById('mb-purge-date').value = '';
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
document.getElementById('mb-purge-submit').disabled = true;
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
}
// Primary: wrap Joomla.submitbutton so the Purge toolbar button opens the
// modal instead of submitting the no-op backups.purgeModal task. This is
// resilient to how the Atum toolbar renders the button markup.
if (window.Joomla && typeof Joomla.submitbutton === 'function') {
var origSubmitbutton = Joomla.submitbutton;
Joomla.submitbutton = function(task) {
if (task === 'backups.purgeModal') {
openPurgeModal();
return false;
}
return origSubmitbutton.apply(this, arguments);
};
}
document.addEventListener('DOMContentLoaded', function() {
// Fallback: if the button still exposes an inline onclick, bind directly.
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
if (purgeBtn) {
purgeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
openPurgeModal();
return false;
}, true);
}
// Date change triggers count lookup with debounce
var dateInput = document.getElementById('mb-purge-date');
if (dateInput) {
dateInput.addEventListener('change', function() {
if (purgeCountTimer) clearTimeout(purgeCountTimer);
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
});
}
// Purge modal close handled by Bootstrap data-bs-dismiss
// Confirm on submit
var purgeForm = document.getElementById('mb-purge-form');
if (purgeForm) {
purgeForm.addEventListener('submit', function(e) {
var msg = document.getElementById('mb-purge-count-msg').textContent;
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
e.preventDefault();
}
});
}
});
function fetchPurgeCount() {
var dateVal = document.getElementById('mb-purge-date').value;
var countWrapper = document.getElementById('mb-purge-count-wrapper');
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
var countMsg = document.getElementById('mb-purge-count-msg');
var submitBtn = document.getElementById('mb-purge-submit');
if (!dateVal) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
return;
}
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
var form = new URLSearchParams();
form.append('task', 'ajax.countPurge');
form.append('date', dateVal);
form.append(PURGE_TOKEN, '1');
fetch(PURGE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
countMsg.textContent = data.message || 'Error';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
} else if (data.count === 0) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'block';
submitBtn.disabled = true;
} else {
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
countMsg.textContent = text.replace('%d', data.count);
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = false;
}
})
.catch(function(err) {
countMsg.textContent = 'Error: ' + err.message;
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
});
}
})();
</script>
<?php endif; ?>