Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Field/FolderPickerField.php
T
Jonathan Miller ff7418721d
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
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 Issues (pull_request) Has been cancelled
fix: review findings — key desc, missing changelog, [HOST] domain resolution
- Language: "encrypted" → "base64-encoded" for SSH key description
- CHANGELOG: added 3 missing bug fix entries (fields_values scope, CSRF
  token on Run Backup, SFTP showon/required)
- [HOST] placeholder: resolve domain from Joomla live_site config when
  HTTP_HOST is unavailable (CLI), instead of falling back to system
  hostname (joomla.invalid). Applied to both PlaceholderResolver and
  FolderPickerField.
2026-06-23 17:20:05 -05:00

586 lines
23 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
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class FolderPickerField extends FormField
{
protected $type = 'FolderPicker';
protected function getInput(): string
{
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$jRoot = JPATH_ROOT;
// Resolve to absolute for display
$rawValue = $this->value ?: $this->default;
if ($rawValue && $rawValue[0] !== '/') {
$absPath = $jRoot . '/' . $rawValue;
} else {
$absPath = $rawValue;
}
// Build placeholder map for JS resolution
/* Resolve hostname: prefer HTTP_HOST, then Joomla live_site config, then system hostname */
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (empty($rawHost) || $rawHost === 'localhost') {
try {
$liveSite = Factory::getApplication()->get('live_site', '');
if (!empty($liveSite)) {
$parsed = parse_url($liveSite, PHP_URL_HOST);
if (!empty($parsed)) {
$rawHost = $parsed;
}
}
} catch (\Throwable $e) {
/* fallback */
}
}
if (empty($rawHost)) {
$rawHost = php_uname('n');
}
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
$siteName = '';
try {
$siteName = Factory::getApplication()->get('sitename', '');
} catch (\Throwable $e) {
// fallback
}
$sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName)));
$placeholders = [
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
'[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) {
$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';
$statusIcon = $exists ? 'icon-publish' : 'icon-unpublish';
$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>";
}
return <<<HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="[HOME]/backups or [DEFAULT_DIR]" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
<span class="icon-question-circle" aria-hidden="true"></span>
</button>
</div>
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
{$statusDetail}
</small>
</div>
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
</div>
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
</div>
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6 class="text-primary">How Path Resolution Works</h6>
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
<div class="card mb-3">
<div class="card-header fw-bold">Absolute Paths</div>
<div class="card-body py-2">
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
<ul class="mb-0">
<li><code>/home/user/backups</code> — Fixed path on the server</li>
<li><code>/var/backups/joomla</code> — System backup directory</li>
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Relative Paths</div>
<div class="card-body py-2">
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
<table class="table table-sm mb-2">
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
<tbody>
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
</tbody>
</table>
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
<div class="card-body py-2">
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
<ul class="mb-0">
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
</ul>
</div>
</div>
<h6 class="text-primary mt-3">Available Placeholders</h6>
<table class="table table-sm table-striped">
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
<tbody>
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
<tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
<tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
</tbody>
</table>
<h6 class="text-primary mt-3">Recommended Configurations</h6>
<table class="table table-sm">
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td><strong>Single site, secure</strong></td>
<td><code>[HOME]/backups</code></td>
<td>Outside web root. Best for most sites.</td>
</tr>
<tr>
<td><strong>Multiple sites on one server</strong></td>
<td><code>[HOME]/backups/[HOST]</code></td>
<td>Each site gets its own subdirectory.</td>
</tr>
<tr>
<td><strong>Date-organized</strong></td>
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
<td>Backups sorted by year and month.</td>
</tr>
<tr>
<td><strong>Per-profile</strong></td>
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
<td>Separate directory for each backup profile.</td>
</tr>
<tr>
<td><strong>Shared hosting (default)</strong></td>
<td><code>[DEFAULT_DIR]</code></td>
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
</tr>
</tbody>
</table>
<div class="alert alert-info py-2 mt-3 mb-0">
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
<div class="card-body p-2">
<div id="{$id}_tree"></div>
</div>
</div>
<script>
(function() {
/* Clickable placeholder insertion at cursor position */
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
/* Move cursor to after the inserted placeholder */
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
/* Trigger input event so status updates */
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
/* Help button — open modal with Bootstrap 5 or fallback */
var helpBtn = document.getElementById('{$id}_helpBtn');
var helpModal = document.getElementById('{$id}_helpModal');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', function(e) {
e.preventDefault();
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
modal.show();
} else {
helpModal.classList.add('show');
helpModal.style.display = 'block';
helpModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = '{$id}_backdrop';
document.body.appendChild(backdrop);
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
helpModal.classList.remove('show');
helpModal.style.display = 'none';
helpModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
var bd = document.getElementById('{$id}_backdrop');
if (bd) bd.remove();
});
}
});
}
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
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;
}
var statusDiv = document.getElementById(fieldId + '_status');
var checkTimer = null;
function setStatus(cssClass, iconClass, message, codePath) {
while (statusDiv.firstChild) statusDiv.removeChild(statusDiv.firstChild);
var small = document.createElement('small');
small.className = cssClass;
var icon = document.createElement('span');
icon.className = iconClass;
icon.setAttribute('aria-hidden', 'true');
small.appendChild(icon);
small.appendChild(document.createTextNode(' ' + message));
if (codePath) {
small.appendChild(document.createTextNode(': '));
var code = document.createElement('code');
code.textContent = codePath;
small.appendChild(code);
}
statusDiv.appendChild(small);
}
function setDefaultDirWarning() {
var warn = document.getElementById(fieldId + '_defaultwarn');
var val = input.value.trim();
var resolved = resolve(val);
var jRoot = placeholders['[DEFAULT_DIR]'].replace(/\/administrator\/components\/com_mokosuitebackup\/backups$/, '');
var isInsideWebRoot = resolved && resolved.indexOf(jRoot) === 0;
var isOldDefault = val === 'administrator/components/com_mokosuitebackup/backups' || val === 'administrator/components/com_mokojoombackup/backups' || val === '[DEFAULT_DIR]';
var showWarning = isOldDefault || isInsideWebRoot;
if (warn) warn.style.display = showWarning ? 'block' : 'none';
}
function checkDirPermissions() {
var val = input.value.trim();
if (!val) return;
setStatus('text-muted', 'icon-spinner icon-spin', 'Checking...', null);
setDefaultDirWarning();
var form = new URLSearchParams();
form.append('task', 'ajax.checkDir');
form.append('path', val);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
.then(function(data) {
if (data.error) {
setStatus('text-danger', 'icon-unpublish', data.message || 'Error', null);
return;
}
if (data.placeholder) {
setStatus('text-info', 'icon-info-circle', 'Uses placeholders (resolved at backup time)', data.resolved);
return;
}
if (data.writable) {
setStatus('text-success', 'icon-publish', 'Writable', data.resolved);
} else if (data.exists) {
setStatus('text-warning', 'icon-warning-circle', 'Exists but not writable', data.resolved);
} else {
setStatus('text-danger', 'icon-unpublish', 'Directory not found', data.resolved);
}
})
.catch(function(err) {
setStatus('text-danger', 'icon-unpublish', 'Check failed: ' + err.message, null);
});
}
/* Show which placeholders are in use and their resolved values */
var resolvedDiv = document.getElementById(fieldId + '_resolved');
function updateResolvedDisplay() {
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
var val = input.value || '';
var found = false;
for (var key in placeholders) {
if (val.indexOf(key) !== -1 && placeholders[key]) {
found = true;
var badge = document.createElement('span');
badge.className = 'badge bg-light text-dark border me-1 mb-1';
badge.style.fontSize = '0.75rem';
badge.style.fontFamily = 'monospace';
var keySpan = document.createElement('strong');
keySpan.textContent = key;
badge.appendChild(keySpan);
badge.appendChild(document.createTextNode(' = '));
var valSpan = document.createElement('span');
valSpan.className = 'text-primary';
valSpan.textContent = placeholders[key];
badge.appendChild(valSpan);
resolvedDiv.appendChild(badge);
}
}
if (found) {
var fullResolved = document.createElement('div');
fullResolved.className = 'mt-1';
var arrow = document.createElement('span');
arrow.className = 'text-muted';
arrow.textContent = 'EXAMPLE: ';
fullResolved.appendChild(arrow);
var code = document.createElement('code');
code.textContent = resolve(val);
fullResolved.appendChild(code);
resolvedDiv.appendChild(fullResolved);
}
}
input.addEventListener('input', function() {
clearTimeout(checkTimer);
updateResolvedDisplay();
checkTimer = setTimeout(checkDirPermissions, 400);
});
input.addEventListener('change', function() {
clearTimeout(checkTimer);
checkDirPermissions();
});
btn.addEventListener('click', function() {
if (browser.style.display !== 'none') {
browser.style.display = 'none';
return;
}
browser.style.display = 'block';
// Resolve placeholders before browsing so the server sees real paths
loadDir(resolve(input.value || '/'));
});
function loadDir(path) {
tree.textContent = 'Loading...';
var form = new URLSearchParams();
form.append('task', 'ajax.browseDir');
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
.then(function(data) {
if (data.error) {
tree.textContent = data.message || 'Error loading directory';
return;
}
renderTree(data, path);
})
.catch(function(err) {
tree.textContent = 'Error: ' + err.message;
});
}
function renderTree(data, path) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
if (data.parent) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' ..'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadDir(data.parent);
});
list.appendChild(up);
}
(data.dirs || []).forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var icon = document.createElement('span');
icon.className = 'icon-folder';
icon.setAttribute('aria-hidden', 'true');
item.appendChild(icon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
// Store with placeholders reversed back in
input.value = unresolve(dir.path);
checkDirPermissions();
loadDir(dir.path);
});
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = unresolve(dir.path);
checkDirPermissions();
browser.style.display = 'none';
});
list.appendChild(item);
});
tree.appendChild(list);
var info = document.createElement('div');
info.className = 'mt-2 p-1';
var small = document.createElement('small');
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);
}
// Run initial check on page load
setDefaultDirWarning();
updateResolvedDisplay();
checkDirPermissions();
})();
</script>
HTML;
}
}