Remember the last ten manual syncs so a repeat is one click

Retyping two paths and nine checkboxes correctly every time is where the
mistakes come from, and "one-off" described how a transfer is scheduled rather
than how often it is run. Pinned entries are exempt from the rotation — the
command used twice a year is both the most worth keeping and the first that ten
ordinary runs evict. Stored server-side, so the list is there from any screen,
and loading one fills the form and stops rather than running it.
This commit is contained in:
Gmer4Lfe
2026-08-14 17:38:48 -04:00
parent 976fdf6e50
commit 793a75b8a2
2 changed files with 268 additions and 3 deletions
+133
View File
@@ -147,6 +147,22 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-ms-dir:hover { background:#0f1f0f;color:#4caf50; }
.vv-ms-dir.parent { color:#444;font-style:italic; }
.vv-ms-dir.parent:hover { color:#888; }
/* Recent-sync rows. The whole row loads the entry; the controls on the right are the exceptions,
which is why they stop the click rather than the row opting in. */
.vv-ms-rec { display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:3px;
background:#0d0d0d;border:1px solid #1a1a1a;margin-bottom:3px;cursor:pointer; }
.vv-ms-rec:hover { background:#101a10;border-color:#2d4a2d; }
.vv-ms-rec-nm { font-size:11px;color:#bbb;flex-shrink:0;max-width:38%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap; }
.vv-ms-rec-pt { font-size:10px;color:#3a5a7a;font-family:monospace;flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-ms-rec-age { font-size:9px;color:#333;flex-shrink:0; }
.vv-ms-rec-del { font-size:9px;color:#7a3030;flex-shrink:0; }
.vv-ms-rec-b { background:none;border:none;color:#3a3a3a;cursor:pointer;font-size:11px;
padding:0 3px;flex-shrink:0;line-height:1; }
.vv-ms-rec-b:hover { color:#bbb; }
.vv-ms-rec-b.on { color:#ffb74d; }
.vv-ms-optrow { display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:8px; }
.vv-ms-chk { display:flex;align-items:center;gap:5px;cursor:pointer; }
.vv-ms-chk input { accent-color:#4caf50;width:13px;height:13px; }
@@ -179,6 +195,19 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
</div>
<div id="vv-ms-body" style="display:none;">
<!-- ── Recent syncs ──────────────────────────────────────────────────────
First, not last. The reason to open this card is usually a copy that has been done
before, and putting the recall list under the form means scrolling past the very thing
you were trying to avoid filling in again. -->
<div id="vv-ms-recent-wrap" style="margin-bottom:10px;display:none;">
<div class="vv-ms-lbl" style="margin-bottom:5px;">
Recent — click to load
<span style="font-size:9px;font-weight:normal;color:#2a2a2a;margin-left:4px;">
pinned are kept, the rest roll off after ten</span>
</div>
<div id="vv-ms-recent"></div>
</div>
<!-- Two-column browse layout -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:10px;">
@@ -1629,6 +1658,7 @@ function vvMsToggle() {
if (!open) {
if (!_vvMsHosts.length) _vvMsLoadHosts();
else vvMsUpdatePreview();
vvMsLoadRecent(); // refreshed on open, so another tab or device is reflected here
}
}
@@ -1933,6 +1963,7 @@ async function vvMsRun() {
}
stat.textContent = 'Running…';
stopBtn.dataset.token = d.token;
vvMsLoadRecent(); // the run just recorded itself server-side; show it
stopBtn.style.display = '';
_vvMsStartPoll(d.token, btn, stat, out, badge, stopBtn);
})
@@ -1980,6 +2011,108 @@ function _vvMsStartPoll(token, btn, stat, out, badge, stopBtn) {
}, 1500);
}
// ── Recent manual syncs ──────────────────────────────────────────────────────
// Server-side, not localStorage: the same install is driven from two desktop screens and a phone,
// and a recall list that only exists in the browser that made it is missing exactly when wanted.
const _MS_API = '/plugins/varaverk/api/manual_sync.php';
let _vvMsRecent = [];
function _msRecAge(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - (ts || 0));
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm';
if (s < 86400) return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
function vvMsLoadRecent() {
fetch(_MS_API + '?action=recent')
.then(r => r.json())
.then(d => { _vvMsRecent = (d && d.rows) || []; _msRenderRecent(); })
.catch(() => {});
}
function _msRenderRecent() {
const wrap = document.getElementById('vv-ms-recent-wrap');
const box = document.getElementById('vv-ms-recent');
if (!wrap || !box) return;
if (!_vvMsRecent.length) { wrap.style.display = 'none'; box.innerHTML = ''; return; }
wrap.style.display = '';
box.innerHTML = _vvMsRecent.map(r => {
// Falls back to the transfer itself when unnamed, so a row always says what it does. A blank
// label on a one-click action that copies files is not a thing to ship.
const label = r.name || (String(r.local || '').split('/').filter(Boolean).pop() || 'sync');
const path = (r.local || '') + ' → ' + (r.slot || '') + ':' + (r.rpath || '');
const del = (r.flags || []).includes('--delete');
return `<div class="vv-ms-rec" data-id="${vvEscAttr(r.id)}" title="${vvEscAttr(path)}">
<span class="vv-ms-rec-nm">${vvEscHtml(label)}</span>
<span class="vv-ms-rec-pt">${vvEscHtml(path)}</span>
${del ? '<span class="vv-ms-rec-del">--delete</span>' : ''}
<span class="vv-ms-rec-age">${_msRecAge(r.ts)}</span>
<button class="vv-ms-rec-b ${r.sticky ? 'on' : ''}" data-act="sticky"
title="${r.sticky ? 'Unpin — may roll off' : 'Pin — never rolls off'}">${r.sticky ? '★' : '☆'}</button>
<button class="vv-ms-rec-b" data-act="rename" title="Rename">✎</button>
<button class="vv-ms-rec-b" data-act="delete" title="Forget this entry">×</button>
</div>`;
}).join('');
}
// One delegated listener: the list is replaced wholesale on every change, so per-row handlers
// would be rebound constantly and silently missed by anything added later.
document.addEventListener('click', async ev => {
const row = ev.target.closest('.vv-ms-rec');
if (!row) return;
const id = row.dataset.id;
const rec = _vvMsRecent.find(r => r.id === id);
if (!rec) return;
const btn = ev.target.closest('.vv-ms-rec-b');
if (!btn) { _msApplyRecent(rec); return; }
ev.stopPropagation();
const act = btn.dataset.act;
const body = { action: 'recent_update', id: id, op: act };
if (act === 'rename') {
const name = await vvPrompt('Name for this sync', rec.name || '');
if (name === null) return;
body.name = name;
} else if (act === 'sticky') {
body.value = rec.sticky ? '0' : '1';
} else if (act === 'delete') {
if (!await vvConfirm('Forget this saved sync? It only removes the entry, nothing is deleted on either host.')) return;
}
fetch(_MS_API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams(body) })
.then(r => r.json())
.then(() => vvMsLoadRecent())
.catch(() => {});
});
// Fills the form and stops. Deliberately does not run: one click away from a transfer that may
// carry --delete is not a saving worth having, and the point of loading it is to look at it.
function _msApplyRecent(r) {
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
set('vv-ms-local', r.local || '');
set('vv-ms-rpath', r.rpath || '');
set('vv-ms-bw', r.bw || 0);
const host = document.getElementById('vv-ms-host');
if (host) { host.value = r.slot || ''; if (typeof vvMsHostChanged === 'function') vvMsHostChanged(); }
const user = document.getElementById('vv-ms-user');
if (user && r.user) user.value = r.user;
const key = document.getElementById('vv-ms-usekey');
if (key) key.checked = r.use_key !== false;
const want = new Set(r.flags || []);
document.querySelectorAll('#vv-ms-card input[data-flag]').forEach(el => {
el.checked = want.has(el.dataset.flag);
});
vvMsUpdatePreview();
document.getElementById('vv-ms-local')?.focus();
}
// ── Assistant ────────────────────────────────────────────────────────────────
// Scope is fixed to the tab rather than following a selection. Unlike the Scheduler, nothing here
// is "open" in the sense of one log or one script — the operator is looking at the whole picture