Let a sync window take a whole share in one click

The window editor is path-first, which is exact but slow for the case that is nearly all of them.
The chips only offer whole shares; a subpath already in the window marks its share partial rather
than offering to widen it, and the path box stays the only way to express a subpath or a profile.
This commit is contained in:
Gmer4Lfe
2026-08-23 17:16:24 -04:00
parent fbf2b92167
commit 28987240aa
2 changed files with 115 additions and 0 deletions
+32
View File
@@ -91,6 +91,38 @@ require_once dirname(__DIR__) . '/include/monitor.php';
// ── Live sync log ───────────────────────────────────────────────────────────── // ── Live sync log ─────────────────────────────────────────────────────────────
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
// ── Share list, for the quick-select strip on the window editor ──────────────────────────────
// The editor is path-first — type or browse, then Add — which is exact but slow for the case that
// is nearly all of them: add one whole share. This answers "what shares exist here", so the strip
// can offer them as one click each.
//
// Read from /boot/config/shares/*.cfg rather than by listing /mnt/user, because a share is a
// declared thing: the directory can be absent on a share that has never been written to, and
// listing the mount would also invent entries for stray directories that are not shares at all.
if ($action === 'shares') {
$out = [];
foreach (glob('/boot/config/shares/*.cfg') ?: [] as $cfg) {
$name = basename($cfg, '.cfg');
$raw = (string) @file_get_contents($cfg);
$get = function (string $k) use ($raw): string {
return preg_match('/^' . $k . '="([^"]*)"/m', $raw, $m) ? $m[1] : '';
};
$path = '/mnt/user/' . $name;
$out[] = [
'name' => $name,
'path' => $path,
'comment' => $get('shareComment'),
'pool' => $get('shareCachePool'),
'cache' => $get('shareUseCache'),
// Present is not the same as declared — a share can exist in conf with no directory yet.
'exists' => is_dir($path),
];
}
usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name']));
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'shares' => $out]);
exit;
}
if ($action === 'rsync_log') { if ($action === 'rsync_log') {
$lockDir = '/tmp/unraid_locks'; $lockDir = '/tmp/unraid_locks';
$lines = []; $lines = [];
+83
View File
@@ -40,6 +40,19 @@ require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php'; require_once dirname(__DIR__) . '/include/ai_chat.php';
?> ?>
<style> <style>
/* ── Quick-add share chips ───────────────────────────────────────────────────────────────────
A chip is a share, not a path. Anything already covered by this window is shown dimmed and
inert rather than hidden — a share missing from the strip would read as "not on this host",
which is a different and wrong statement. */
.vv-ry-chips { display:flex;flex-wrap:wrap;gap:3px;max-height:96px;overflow-y:auto;
padding:4px;background:#0a0a0a;border:1px solid #161616;border-radius:3px; }
.vv-ry-chip { font-size:10px;padding:2px 7px;border-radius:10px;cursor:pointer;
background:#0e1a2a;color:#7ab;border:1px solid #1e3a5a;white-space:nowrap;
user-select:none; }
.vv-ry-chip:hover { background:#14263d; }
.vv-ry-chip.in { background:#0d1a0d;color:#4a7a4a;border-color:#1f3a1f;cursor:default; }
.vv-ry-chip.part { background:#1a1408;color:#a08a4a;border-color:#3a2f1a; }
.vv-ry-chip.gone { opacity:.45; }
.vv-ry-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px;min-width:0; } .vv-ry-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px;min-width:0; }
.vv-ry-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; } .vv-ry-sec { font-size:10px;font-weight:bold;color:#444;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px; }
.vv-ry-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; } .vv-ry-row { display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin:2px 0; }
@@ -1237,6 +1250,15 @@ function _vvRyBuildEditPanel(key) {
} }
// Share add form // Share add form
h += `<div style="margin-top:6px;" onclick="event.stopPropagation()"> h += `<div style="margin-top:6px;" onclick="event.stopPropagation()">
<!-- Quick select. The path box below stays the precise tool — subpaths like
/mnt/user/Media_Server/Emby and per-entry profiles can only be expressed there. This
strip only covers the common case: add one whole share, one click. -->
<div style="margin-bottom:5px;">
<div style="font-size:9px;color:#4a6a80;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px;">
Quick add a share <span style="color:#3a3a3a;text-transform:none;letter-spacing:0;">— uses the profile selected below</span>
</div>
<div id="vv-ry-echips-${key}" class="vv-ry-chips">loading…</div>
</div>
<div style="display:flex;gap:4px;margin-bottom:3px;"> <div style="display:flex;gap:4px;margin-bottom:3px;">
<input id="vv-ry-epath-${key}" type="text" placeholder="/mnt/user/…" autocomplete="off" <input id="vv-ry-epath-${key}" type="text" placeholder="/mnt/user/…" autocomplete="off"
style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888; style="background:#0d0d0d;border:1px solid #1e1e1e;border-radius:3px;color:#888;
@@ -1272,6 +1294,9 @@ function _vvRyBuildEditPanel(key) {
panel.innerHTML = h; panel.innerHTML = h;
_vvRyLoadProfiles(key); _vvRyLoadProfiles(key);
// After innerHTML, not before — the chip host does not exist until the panel is written, and
// the strip has to redraw on every rebuild so an entry added or removed re-marks its share.
vvRyRenderChips(key);
} }
// ── Script mutations ────────────────────────────────────────────────────────── // ── Script mutations ──────────────────────────────────────────────────────────
@@ -1302,6 +1327,64 @@ function vvRyERemShare(key, idx) {
const s = _vvRyWinEState[key]?.shares; if (!s) return; const s = _vvRyWinEState[key]?.shares; if (!s) return;
s.splice(idx, 1); _vvRyBuildEditPanel(key); s.splice(idx, 1); _vvRyBuildEditPanel(key);
} }
// ── Quick-add share chips ────────────────────────────────────────────────────────────────────
// Fetched once per page and reused by every window's editor; rebuilding 41 chips per panel render
// is cheap, another HTTP round trip per open is not.
let _vvRyShares = null;
async function _vvRyLoadShares() {
if (_vvRyShares) return _vvRyShares;
try {
const d = await (await fetch('/plugins/varaverk/api/rsync.php?action=shares')).json();
_vvRyShares = d.ok ? (d.shares || []) : [];
} catch (e) { _vvRyShares = []; }
return _vvRyShares;
}
// A share is "in" only when this window holds its exact path. A window holding a SUBPATH of it —
// /mnt/user/Media_Server/Emby against the Media_Server share — is marked partial instead, because
// adding the parent would silently widen a deliberately narrow entry.
function _vvRyChipState(share, entries) {
const p = (share.path || '').replace(/\/+$/, '');
for (const e of entries) {
const ep = (e.path || '').replace(/\/+$/, '');
if (ep === p) return 'in';
if (ep.startsWith(p + '/')) return 'part';
}
return '';
}
async function vvRyRenderChips(key) {
const host = document.getElementById('vv-ry-echips-' + key);
if (!host) return;
const shares = await _vvRyLoadShares();
const entries = (_vvRyWinEState[key] && _vvRyWinEState[key].shares) || [];
if (!shares.length) { host.innerHTML = '<span style="font-size:10px;color:#444;">no shares found</span>'; return; }
host.innerHTML = shares.map(function (s) {
const st = _vvRyChipState(s, entries);
const cls = 'vv-ry-chip' + (st ? ' ' + st : '') + (s.exists ? '' : ' gone');
const tip = st === 'in' ? 'already in this window'
: st === 'part' ? 'a subpath of this share is already here — adding the whole share would widen it'
: (s.comment || s.path) + (s.exists ? '' : ' (declared, but no directory yet)');
return '<span class="' + cls + '" data-path="' + vvEscAttr(s.path) + '" data-state="' + st + '" '
+ 'title="' + vvEscAttr(tip) + '">' + vvEscHtml(s.name) + '</span>';
}).join('');
// Delegated, and bound once — the chips are rebuilt on every render.
if (!host.dataset.bound) {
host.dataset.bound = '1';
host.addEventListener('click', function (ev) {
const c = ev.target.closest('.vv-ry-chip');
if (!c || c.dataset.state === 'in') return;
const pathEl = document.getElementById('vv-ry-epath-' + key);
if (pathEl) pathEl.value = c.dataset.path;
// Routed through the existing add so profile handling, validation and the state shape stay
// in exactly one place.
vvRyEAddShare(key);
});
}
}
function vvRyEAddShare(key) { function vvRyEAddShare(key) {
const path = document.getElementById('vv-ry-epath-' + key)?.value.trim(); const path = document.getElementById('vv-ry-epath-' + key)?.value.trim();
const prof = document.getElementById('vv-ry-eprof-' + key)?.value.trim() || ''; const prof = document.getElementById('vv-ry-eprof-' + key)?.value.trim() || '';