feat: JS placeholder resolution in FolderPicker for portable profiles
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
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled

FolderPickerField now resolves [site_name], [host], [date], etc. in
JavaScript before browsing directories, and reverse-replaces actual
values back to placeholders when user selects a path. This makes
profiles portable across sites.

Default placeholder hint uses [host] for directory paths.

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-06 17:52:42 -05:00
parent f6c9f9b78d
commit ca7ab8f363
@@ -12,6 +12,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
@@ -35,13 +36,43 @@ class FolderPickerField extends FormField
$absPath = $rawValue;
}
// If path contains placeholders, show info status instead of checking disk
// Build placeholder map for JS resolution
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$siteName = '';
try {
$siteName = Factory::getApplication()->get('sitename', '');
} catch (\Throwable $e) {
// fallback
}
$sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName)));
$placeholders = [
'[host]' => $hostname,
'[site_name]' => $sanitizedSiteName ?: 'joomla',
'[profile_id]' => '1',
'[profile_name]' => 'default',
'[type]' => 'full',
'[year]' => date('Y'),
'[month]' => date('m'),
'[day]' => date('d'),
'[date]' => date('Ymd'),
];
$placeholdersJson = json_encode($placeholders);
// Resolve placeholders for the status display
$resolvedPath = str_replace(array_keys($placeholders), array_values($placeholders), $absPath);
$hasPlaceholders = preg_match('/\[.+\]/', $absPath);
if ($hasPlaceholders) {
$statusClass = 'text-info';
$statusIcon = 'icon-info-circle';
$exists = is_dir($resolvedPath);
$statusClass = $exists ? 'text-success' : 'text-info';
$statusIcon = $exists ? 'icon-publish' : 'icon-info-circle';
$statusText = Text::_('COM_MOKOJOOMBACKUP_FOLDER_PLACEHOLDER');
$resolvedSafe = htmlspecialchars($resolvedPath, ENT_QUOTES, 'UTF-8');
$statusDetail = "{$statusText}: <code>{$resolvedSafe}</code>";
} else {
$exists = is_dir($absPath);
$statusClass = $exists ? 'text-success' : 'text-danger';
@@ -49,24 +80,24 @@ class FolderPickerField extends FormField
$statusText = $exists
? Text::_('COM_MOKOJOOMBACKUP_FOLDER_EXISTS')
: Text::_('COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND');
$absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
$statusDetail = "{$statusText}: <code>{$absPathSafe}</code>";
}
$absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
return <<<HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/home/user/backups or administrator/components/com_mokojoombackup/backups" />
placeholder="/home/user/backups/[host] or administrator/components/com_mokojoombackup/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
</div>
<div class="mt-1">
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
{$statusText}: <code>{$absPathSafe}</code>
{$statusDetail}
</small>
</div>
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
@@ -81,6 +112,25 @@ class FolderPickerField extends FormField
var browser = document.getElementById(fieldId + '_browser');
var tree = document.getElementById(fieldId + '_tree');
var input = document.getElementById(fieldId);
var placeholders = {$placeholdersJson};
// Resolve placeholders in a path (forward: [site_name] -> actual value)
function resolve(path) {
for (var key in placeholders) {
path = path.split(key).join(placeholders[key]);
}
return path;
}
// Reverse-replace actual values back to placeholders for portable storage
function unresolve(path) {
for (var key in placeholders) {
if (placeholders[key] && placeholders[key].length > 1) {
path = path.split(placeholders[key]).join(key);
}
}
return path;
}
btn.addEventListener('click', function() {
if (browser.style.display !== 'none') {
@@ -88,7 +138,8 @@ class FolderPickerField extends FormField
return;
}
browser.style.display = 'block';
loadDir(input.value || '/');
// Resolve placeholders before browsing so the server sees real paths
loadDir(resolve(input.value || '/'));
});
function loadDir(path) {
@@ -152,12 +203,13 @@ class FolderPickerField extends FormField
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
input.value = dir.path;
// Store with placeholders reversed back in
input.value = unresolve(dir.path);
loadDir(dir.path);
});
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = dir.path;
input.value = unresolve(dir.path);
browser.style.display = 'none';
});
list.appendChild(item);
@@ -171,6 +223,13 @@ class FolderPickerField extends FormField
small.className = 'text-muted';
small.textContent = 'Current: ' + (data.current || path);
info.appendChild(small);
// Show what will be stored (with placeholders)
var stored = document.createElement('small');
stored.className = 'text-info d-block';
stored.textContent = 'Stored as: ' + unresolve(data.current || path);
info.appendChild(stored);
tree.appendChild(info);
}
})();