feat: interactive directory tree browser for exclude filters
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
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

Replace plain text ExcludeList with DirectoryFilter field that provides
a browsable server directory tree with checkboxes, removable pills, and
manual path entry. Backward compatible storage format.

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 07:24:33 -05:00
parent df59fd7303
commit 2755ef709f
3 changed files with 264 additions and 2 deletions
@@ -124,7 +124,7 @@
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
<field
name="exclude_dirs"
type="ExcludeList"
type="DirectoryFilter"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
filter="raw"
@@ -102,7 +102,10 @@ COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone r
; Exclusion filter fields
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC="One directory path per line (relative to Joomla root). These directories will be skipped during file backup."
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
COM_MOKOBACKUP_FILTER_EXCLUDED="Excluded"
COM_MOKOBACKUP_FILTER_INCLUDED="Included"
COM_MOKOBACKUP_FILTER_ADD_MANUAL="Add Path"
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES="Exclude Files"
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC="One filename or pattern per line. Supports wildcards (e.g. *.bak, *.tmp)."
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES="Exclude Tables"
@@ -0,0 +1,259 @@
<?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
*
* Interactive directory tree field with checkboxes for exclude/include filtering.
* Loads the directory tree from the server via AJAX (browseDir endpoint).
*/
namespace Joomla\Component\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class DirectoryFilterField extends FormField
{
protected $type = 'DirectoryFilter';
protected function getInput(): string
{
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$mode = htmlspecialchars((string) ($this->element['mode'] ?? 'exclude'), ENT_QUOTES, 'UTF-8');
// Parse current values (newline-separated)
$items = [];
if (!empty($this->value)) {
$items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))));
}
$itemsJson = json_encode($items);
$jRoot = json_encode(JPATH_ROOT);
$labelExclude = Text::_('COM_MOKOBACKUP_FILTER_EXCLUDED');
$labelInclude = Text::_('COM_MOKOBACKUP_FILTER_INCLUDED');
$labelManual = Text::_('COM_MOKOBACKUP_FILTER_ADD_MANUAL');
$addLabel = Text::_('JGLOBAL_FIELD_ADD');
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? 'path/to/directory'), ENT_QUOTES, 'UTF-8');
return <<<HTML
<div id="{$id}_wrap">
<input type="hidden" name="{$name}" id="{$id}" value="" />
<!-- Manual entry row -->
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" id="{$id}_manual" placeholder="{$placeholder}" />
<button type="button" class="btn btn-outline-success" id="{$id}_addBtn">
<span class="icon-plus" aria-hidden="true"></span> {$addLabel}
</button>
</div>
<!-- Selected items (pills) -->
<div id="{$id}_pills" class="mb-2 d-flex flex-wrap gap-1"></div>
<!-- Browsable tree -->
<div class="card">
<div class="card-header py-1 px-2 d-flex justify-content-between align-items-center">
<small class="fw-bold text-muted" id="{$id}_cwd"></small>
<button type="button" class="btn btn-sm btn-link p-0" id="{$id}_upBtn" style="display:none;">
<span class="icon-arrow-up-4" aria-hidden="true"></span> ..
</button>
</div>
<div id="{$id}_tree" class="list-group list-group-flush" style="max-height:300px; overflow-y:auto;"></div>
</div>
</div>
<style>
#{$id}_wrap .mb-dir-pill {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
font-family: monospace; cursor: default;
}
#{$id}_wrap .mb-dir-pill.excluded { background: #f8d7da; color: #842029; border: 1px solid #f5c2c7; }
#{$id}_wrap .mb-dir-pill.included { background: #d1e7dd; color: #0f5132; border: 1px solid #badbcc; }
#{$id}_wrap .mb-dir-pill .btn-close { font-size: 0.6rem; }
#{$id}_wrap .mb-dir-row { display: flex; align-items: center; padding: 0.35rem 0.75rem; gap: 0.5rem; border-bottom: 1px solid #eee; }
#{$id}_wrap .mb-dir-row:hover { background: #f8f9fa; }
#{$id}_wrap .mb-dir-row .mb-dir-name { cursor: pointer; flex: 1; font-size: 0.9rem; }
#{$id}_wrap .mb-dir-row .mb-dir-name:hover { color: #0d6efd; text-decoration: underline; }
#{$id}_wrap .mb-dir-check { width: 1rem; height: 1rem; cursor: pointer; }
</style>
<script>
(function() {
const id = '{$id}';
const hidden = document.getElementById(id);
const pills = document.getElementById(id + '_pills');
const tree = document.getElementById(id + '_tree');
const cwdEl = document.getElementById(id + '_cwd');
const upBtn = document.getElementById(id + '_upBtn');
const manualInput = document.getElementById(id + '_manual');
const addBtn = document.getElementById(id + '_addBtn');
const jRoot = {$jRoot};
let selected = new Set({$itemsJson});
let currentPath = jRoot;
let parentPath = null;
function sync() {
hidden.value = Array.from(selected).join('\\n');
renderPills();
}
function renderPills() {
while (pills.firstChild) pills.removeChild(pills.firstChild);
selected.forEach(function(path) {
const pill = document.createElement('span');
pill.className = 'mb-dir-pill excluded';
const icon = document.createElement('span');
icon.className = 'icon-folder';
icon.setAttribute('aria-hidden', 'true');
pill.appendChild(icon);
pill.appendChild(document.createTextNode(' ' + path + ' '));
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn-close btn-close-sm';
closeBtn.setAttribute('aria-label', 'Remove');
closeBtn.addEventListener('click', function() {
selected.delete(path);
sync();
refreshTree();
});
pill.appendChild(closeBtn);
pills.appendChild(pill);
});
}
function toRelative(absPath) {
if (absPath.indexOf(jRoot) === 0) {
let rel = absPath.substring(jRoot.length);
if (rel.charAt(0) === '/') rel = rel.substring(1);
return rel;
}
return absPath;
}
function setTreeMessage(text, cls) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
const msg = document.createElement('div');
msg.className = 'p-2 ' + cls;
msg.textContent = text;
tree.appendChild(msg);
}
function loadDir(path) {
setTreeMessage('Loading...', 'text-muted');
currentPath = path;
const form = new URLSearchParams();
form.append('task', 'ajax.browseDir');
form.append('path', path);
const tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokobackup&format=json', {
method: 'POST', body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
setTreeMessage(data.message || 'Error', 'text-danger');
return;
}
parentPath = data.parent || null;
cwdEl.textContent = data.current || path;
upBtn.style.display = parentPath ? '' : 'none';
renderTree(data.dirs || []);
})
.catch(function(err) {
setTreeMessage('Error: ' + err.message, 'text-danger');
});
}
function refreshTree() {
loadDir(currentPath);
}
function renderTree(dirs) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
if (dirs.length === 0) {
setTreeMessage('(empty)', 'text-muted');
return;
}
dirs.forEach(function(dir) {
const rel = toRelative(dir.path);
const isExcluded = selected.has(rel);
const row = document.createElement('div');
row.className = 'mb-dir-row' + (isExcluded ? ' bg-danger bg-opacity-10' : '');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'mb-dir-check form-check-input';
cb.checked = isExcluded;
cb.title = isExcluded ? 'Excluded — uncheck to include' : 'Check to exclude';
cb.addEventListener('change', function() {
if (cb.checked) {
selected.add(rel);
} else {
selected.delete(rel);
}
sync();
refreshTree();
});
const icon = document.createElement('span');
icon.className = isExcluded ? 'icon-unpublish text-danger' : 'icon-folder text-warning';
icon.setAttribute('aria-hidden', 'true');
const nameEl = document.createElement('span');
nameEl.className = 'mb-dir-name';
nameEl.textContent = dir.name;
nameEl.addEventListener('click', function() { loadDir(dir.path); });
row.appendChild(cb);
row.appendChild(icon);
row.appendChild(nameEl);
tree.appendChild(row);
});
}
upBtn.addEventListener('click', function() {
if (parentPath) loadDir(parentPath);
});
addBtn.addEventListener('click', function() {
const val = manualInput.value.trim();
if (val && !selected.has(val)) {
selected.add(val);
manualInput.value = '';
sync();
refreshTree();
}
});
manualInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
});
sync();
loadDir(jRoot);
})();
</script>
HTML;
}
}