Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.
- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
token → throw — you'd otherwise brick the dashboard with no recovery short of
editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
Both use __authState (mutable seam) so they're testable. __authState.bound set
from cfg.host. When the flag is off, the guards are skipped (back-compat
escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
(0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
relayConfigured, foundryConfigured} — booleans only, no secret values),
POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
token = unset; no token configured → 401), POST /api/auth/logout (clears
cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
&& no stored token), a shared apiFetch wrapper (attaches the stored token as a
Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
tests): auth-status (off/localhost/public/no-secret-leak), login
(valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
(self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).
tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
572 lines
40 KiB
HTML
572 lines
40 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Foundry ⇄ Obsidian merge</title>
|
|
<style>
|
|
:root { color-scheme: light dark; --bg:#1b1b1f; --panel:#26262b; --line:#3a3a42; --txt:#e6e6ea; --mut:#9a9aa4; --acc:#7c9cff; --ok:#6ad06a; --warn:#e0b341; --bad:#e06a6a; --pur:#b98cff; }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font:14px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--txt); }
|
|
header { position:sticky; top:0; z-index:2; background:var(--panel); border-bottom:1px solid var(--line); padding:10px 16px; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
|
|
h1 { font-size:15px; margin:0; font-weight:600; }
|
|
.counts { display:flex; gap:14px; font-size:13px; color:var(--mut); }
|
|
.counts b { color:var(--txt); }
|
|
.spacer { flex:1; }
|
|
button { background:var(--panel); color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; }
|
|
button:hover { border-color:var(--acc); }
|
|
button.primary { background:var(--acc); color:#0c0c10; border-color:var(--acc); font-weight:600; }
|
|
button.danger { border-color:var(--bad); color:var(--bad); }
|
|
button:disabled { opacity:.4; cursor:not-allowed; }
|
|
label { font-size:13px; color:var(--mut); display:flex; gap:6px; align-items:center; }
|
|
main { display:grid; grid-template-columns: 1fr 1fr; gap:0; height:calc(100vh - 53px); }
|
|
section.list { overflow:auto; border-right:1px solid var(--line); }
|
|
section.detail { overflow:auto; padding:14px 16px; }
|
|
.toolbar { padding:8px 16px; display:flex; gap:8px; align-items:center; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:1; }
|
|
input[type=text] { background:var(--panel); border:1px solid var(--line); color:var(--txt); border-radius:6px; padding:5px 9px; width:100%; font-size:13px; }
|
|
table { width:100%; border-collapse:collapse; }
|
|
tr { border-bottom:1px solid var(--line); cursor:pointer; }
|
|
tr:hover { background:var(--panel); }
|
|
tr.sel { background:var(--panel); }
|
|
td { padding:6px 8px; vertical-align:top; font-size:13px; }
|
|
td.name { font-weight:500; }
|
|
.badge { font-size:11px; padding:1px 6px; border-radius:10px; border:1px solid var(--line); color:var(--mut); white-space:nowrap; }
|
|
.badge.ok { color:var(--ok); border-color:var(--ok); }
|
|
.badge.warn { color:var(--warn); border-color:var(--warn); }
|
|
.badge.bad { color:var(--bad); border-color:var(--bad); }
|
|
.badge.acc { color:var(--acc); border-color:var(--acc); }
|
|
.badge.pur { color:var(--pur); border-color:var(--pur); }
|
|
.rowbtns { white-space:nowrap; text-align:right; }
|
|
.rowbtns button { padding:3px 7px; font-size:12px; margin-left:4px; }
|
|
.rowbtns button.rec { border-color:var(--acc); color:var(--acc); }
|
|
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.06em; color:var(--mut); margin:14px 16px 6px; }
|
|
pre { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; font:12px/1.4 ui-monospace,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; }
|
|
.diff-add { color:var(--ok); }
|
|
.diff-del { color:var(--bad); }
|
|
.panel { margin-bottom:16px; }
|
|
.panel h3 { font-size:13px; margin:0 0 6px; color:var(--mut); display:flex; justify-content:space-between; align-items:center; }
|
|
.toast { position:fixed; bottom:14px; left:50%; transform:translateX(-50%); background:var(--panel); border:1px solid var(--acc); border-radius:8px; padding:8px 14px; font-size:13px; max-width:80%; box-shadow:0 6px 24px rgba(0,0,0,.4); }
|
|
.mode-tag { font-size:11px; padding:2px 8px; border-radius:10px; border:1px solid var(--line); }
|
|
.mode-tag.apply { color:var(--bad); border-color:var(--bad); }
|
|
.meta { color:var(--mut); font-size:11px; white-space:nowrap; }
|
|
.rec-panel { margin:10px 16px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
|
|
.rec-panel > summary { cursor:pointer; padding:8px 12px; font-size:13px; font-weight:600; color:var(--acc); list-style:none; }
|
|
.rec-panel > summary::-webkit-details-marker { display:none; }
|
|
.rec-group { display:flex; align-items:center; gap:10px; padding:7px 12px; border-top:1px solid var(--line); font-size:13px; }
|
|
.rec-group .num { font-weight:700; min-width:28px; text-align:right; }
|
|
.rec-group .label { flex:1; }
|
|
.rec-group .label small { color:var(--mut); display:block; font-size:11px; }
|
|
.rec-group button { font-size:12px; padding:3px 9px; }
|
|
.rec-group.active { background: rgba(124,156,255,.10); }
|
|
.rec-group .btns { display:flex; gap:6px; }
|
|
.rec-chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; background:var(--panel); border:1px solid var(--acc); border-radius:10px; padding:2px 4px 2px 8px; white-space:nowrap; }
|
|
.rec-chip button { padding:1px 6px; font-size:11px; border:none; background:transparent; color:var(--mut); }
|
|
.rec-chip button:hover { color:var(--bad); }
|
|
h2 .cnt { color:var(--mut); font-weight:400; font-size:11px; }
|
|
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:10; }
|
|
.modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; width:min(560px,92vw); max-height:80vh; display:flex; flex-direction:column; box-shadow:0 10px 40px rgba(0,0,0,.5); }
|
|
.modal h3 { margin:0; padding:12px 16px; border-bottom:1px solid var(--line); font-size:14px; }
|
|
.modal .modal-body { padding:10px 16px; overflow:auto; }
|
|
.modal .entry { padding:7px 10px; border-radius:6px; cursor:pointer; display:flex; gap:8px; align-items:baseline; }
|
|
.modal .entry:hover { background:var(--bg); }
|
|
.modal .entry .en { font-weight:500; }
|
|
.modal .entry .et { font-size:11px; color:var(--mut); }
|
|
.legend { margin:10px 16px; font-size:12px; color:var(--mut); line-height:1.6; }
|
|
.legend b { color:var(--txt); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="loginCard" class="modal-bg" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100">
|
|
<div class="modal entry" style="max-width:380px;margin:8vh auto 0;padding:18px">
|
|
<h2 style="margin-top:0">Dashboard auth required</h2>
|
|
<p class="meta">This dashboard is bound to <code id="loginBound">0.0.0.0</code> and requires a token. Enter the <code>DASHBOARD_AUTH_TOKEN</code> you set in your <code>.env</code>.</p>
|
|
<input id="loginToken" type="password" placeholder="DASHBOARD_AUTH_TOKEN" style="width:100%;box-sizing:border-box;margin:8px 0" onkeydown="if(event.key==='Enter')doLogin()">
|
|
<div id="loginErr" class="meta" style="color:var(--bad);min-height:1em"></div>
|
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
<button class="primary" onclick="doLogin()">Log in</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<header>
|
|
<h1>Foundry ⇄ Obsidian merge</h1>
|
|
<div class="counts" id="counts">loading…</div>
|
|
<span class="mode-tag" id="modeTag">dev</span>
|
|
<button onclick="refreshIndex()" title="Re-scan the vault + cc from disk so edits made in Obsidian show as changed. Also happens automatically when you switch back to this tab.">Re-scan</button>
|
|
<div class="spacer"></div>
|
|
<label><input type="checkbox" id="dryRun" checked /> dry-run</label>
|
|
<button class="primary" onclick="act('seedAll')" title="Inject the foundry: identity block into every matched refined note that lacks one. Curation (type/tags/aliases/body) is left untouched. Safe to run repeatedly.">Seed all</button>
|
|
<button class="primary" onclick="act('syncAll')" title="For every matched note: regenerate cc.md from the refined note AND refresh the refined foundry: block. Baselines both sides — writes cc_sync_hash into cc and refreshes foundry.contentHash. Curation flows back to cc.">Sync→cc all</button>
|
|
<button onclick="act('repullAll')" title="For every matched note: regenerate the refined note body from Foundry, preserving curated type/aliases/status tags. Use when Foundry/cc is newer than your vault.">Re-pull all</button>
|
|
<button onclick="act('importAll')" title="Pull every cc-only Foundry entry (not yet in your vault) into a new refined note under refined/imported/<folder>. Un-curated staging for review.">Import all</button>
|
|
<button onclick="pushAll()" title="Push every vault-newer (sync→cc) note into the LIVE Foundry world via the relay in one run. dry-run (default) lists what would be pushed; uncheck dry-run to apply — each live entry is backed up to <out>/bak first and the note's foundry.contentHash is baselined so a re-run only catches new edits. Replaces scripts/resync.ts.">Push all changed</button>
|
|
<button onclick="refreshLive()" title="Rebuild the cached name↔uuid map (for link resolution in pushes) via the relay /search — zero Foundry downtime. The heavy docker-stop full index is CLI-only: npx tsx src/cli.ts refresh --full-index.">Refresh live index</button>
|
|
<button id="autoSyncBtn" onclick="toggleAutosync()" title="Toggle Obsidian→Foundry auto-sync. ON = saving a linked, seeded note in your vault pushes it into LIVE Foundry instantly (guarded by the note's foundry.contentHash baseline, so no-op saves and the post-push baseline write don't re-push; unlinked/unseeded notes are skipped). Foundry→Obsidian is NOT auto — use Sync / Re-pull for that. Needs the relay (RELAY_API_KEY). Dev mode baselines land in the --out mirror; apply mode in the real vault.">Auto-sync: off</button>
|
|
</header>
|
|
<div id="devBanner" class="badge warn" style="display:none;margin:6px 12px;padding:6px 10px">Dev mode — auto-sync disabled; pushes would target the --out mirror, not live Foundry. Start the server with --apply to enable.</div>
|
|
<div id="migrationBanner" class="badge ok" style="display:none;margin:6px 12px;padding:6px 10px;cursor:pointer" title="Click to dismiss. The startup migration stamped foundry.flagsSchemaVersion on notes that lacked it (contentHash/ccHash untouched)."></div>
|
|
<details id="autoSyncPanel" class="autosync-panel" open style="display:none;margin:0 12px;border-top:1px solid #ddd;padding:6px 12px;background:#fafafa">
|
|
<summary style="cursor:pointer">Auto-sync activity <span id="autoSyncCounts" class="meta"></span><span id="autoSyncNote" class="meta" style="margin-left:8px"></span></summary>
|
|
<div id="revertBar" style="margin:6px 0;display:none"><button id="revertBtn" class="bad" onclick="revertLastPush()" title="Restore Foundry to the state captured BEFORE the most recent auto-sync push (a full /update), then re-baseline the note. Use this to undo a wrong push."></button></div>
|
|
<pre id="autoSyncLog" class="autosync-log" style="max-height:180px;overflow:auto;margin:6px 0 8px;font-size:12px;background:#fff;border:1px solid #eee;padding:6px">(no activity yet)</pre>
|
|
</details>
|
|
<main>
|
|
<section class="list">
|
|
<div class="toolbar">
|
|
<input type="text" id="filter" placeholder="filter by name…" oninput="render()" />
|
|
<span id="recFilterChip" class="rec-chip" style="display:none"></span>
|
|
</div>
|
|
|
|
<details class="rec-panel" id="recPanel" open>
|
|
<summary>Recommended next steps ▾</summary>
|
|
<div id="recGroups"></div>
|
|
</details>
|
|
|
|
<details class="legend">
|
|
<summary style="cursor:pointer;list-style:none"><b>How merging works</b> ▾</summary>
|
|
<p>Each file carries a hash on <b>both sides</b>: the refined note stores <code>foundry.contentHash</code>, the cc.md stores <code>cc_sync_hash</code>. The tool compares each side to <i>its own</i> last-synced hash to decide direction — no separate state file. After you edit a side, its hash no longer matches what was recorded at last sync, so that side is "newer".</p>
|
|
<p><b>Priority:</b> content missing from your vault first (import), then unlinked notes (seed), then vault-newer (sync→cc), then cc-newer (re-pull), then both-changed (conflict — review manually).</p>
|
|
</details>
|
|
|
|
<h2 id="matchedH2">Matched (refined ⇄ cc)</h2>
|
|
<table id="matchedTable"><tbody></tbody></table>
|
|
<h2 id="ccOnlyH2">cc-only (import candidates)</h2>
|
|
<table id="ccOnlyTable"><tbody></tbody></table>
|
|
<h2 id="refinedOnlyH2">refined-only (no Foundry link)</h2>
|
|
<table id="refinedOnlyTable"><tbody></tbody></table>
|
|
</section>
|
|
<section class="detail" id="detail">Select a row to inspect.</section>
|
|
</main>
|
|
<script>
|
|
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null, AUTO = null, autoPoll = null, migrationDismissed = false, AUTH_REQUIRED = false;
|
|
const dryEl = () => document.getElementById('dryRun');
|
|
|
|
// E7.2: shared request wrapper. Attaches the stored auth token (set on login) as
|
|
// a Bearer header; on a 401 (token expired / wrong / public bind), shows the
|
|
// login card so the user re-authenticates instead of silently failing.
|
|
function authToken() { return localStorage.getItem('ofs_token') || ''; }
|
|
async function apiFetch(path, init = {}) {
|
|
const tok = authToken();
|
|
if (tok) init = { ...init, headers: { ...(init.headers || {}), authorization: `Bearer ${tok}` } };
|
|
const r = await fetch(path, init);
|
|
if (r.status === 401) { showLogin(); throw new Error('unauthorized'); }
|
|
return r;
|
|
}
|
|
function showLogin() {
|
|
const bound = document.getElementById('loginBound');
|
|
if (bound && STATUS && STATUS.bound) bound.textContent = STATUS.bound;
|
|
document.getElementById('loginCard').style.display = '';
|
|
document.getElementById('loginToken').focus();
|
|
}
|
|
function hideLogin() { document.getElementById('loginCard').style.display = 'none'; }
|
|
async function doLogin() {
|
|
const tok = document.getElementById('loginToken').value.trim();
|
|
if (!tok) { document.getElementById('loginErr').textContent = 'enter the token'; return; }
|
|
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: tok }) }).then(r => r.json()).catch(() => null);
|
|
if (r && r.ok) { localStorage.setItem('ofs_token', tok); hideLogin(); document.getElementById('loginErr').textContent = ''; init(); }
|
|
else { document.getElementById('loginErr').textContent = (r && r.error) ? r.error : 'invalid credentials'; }
|
|
}
|
|
async function doLogout() { localStorage.removeItem('ofs_token'); await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); showLogin(); }
|
|
async function checkAuth() {
|
|
STATUS = await apiFetch('/api/status').then(r => r.json()).catch(() => null);
|
|
const a = await fetch('/api/auth/status').then(r => r.json()).catch(() => null);
|
|
if (STATUS && a) STATUS.bound = a.bound; // merge the bind address for showLogin
|
|
if (a && a.authRequired && !authToken()) { AUTH_REQUIRED = true; showLogin(); return false; }
|
|
return true;
|
|
}
|
|
|
|
// Recommendation -> display label, badge class, bulk op, and one-line guidance.
|
|
// `tag` is the short status noun shown on each row (a state, not an action — so it
|
|
// doesn't read like a button); `label` is the fuller heading used in the rec panel.
|
|
const REC = {
|
|
'import': { label: 'Import into vault', tag: 'cc-only', badge: 'acc', bulk: 'importAll', tip: 'In Foundry/cc but not in your vault. Pull it in as a new (un-curated) refined note to review.' },
|
|
'seed': { label: 'Seed / link', tag: 'unlinked', badge: 'warn', bulk: 'seedAll', tip: 'In your vault and in Foundry, but not yet linked. Inject the foundry: identity block. Curation untouched.' },
|
|
'sync-cc': { label: 'Push to cc', tag: 'vault newer', badge: 'acc', bulk: 'syncAll', tip: 'Your vault copy is newer than the last sync. Regenerate cc.md from the refined note and baseline both sides.' },
|
|
'repull': { label: 'Re-pull', tag: 'cc newer', badge: 'pur', bulk: 'repullAll', tip: 'Foundry/cc is newer than your vault. Regenerate the refined body from Foundry, preserving your curation.' },
|
|
'conflict': { label: 'Conflict', tag: 'conflict', badge: 'bad', bulk: null, tip: 'Both sides changed since last sync. Open the file and decide which side wins manually.' },
|
|
'in-sync': { label: 'In sync', tag: 'in sync', badge: 'ok', bulk: null, tip: 'Neither side changed since last sync. Nothing to do.' },
|
|
'review': { label: 'Review', tag: 'review', badge: 'warn', bulk: null, tip: 'Unlinked or ambiguous — needs a human decision before any action.' },
|
|
};
|
|
// Order in which the guided panel shows groups (missing-from-vault first).
|
|
const REC_ORDER = ['import','seed','sync-cc','repull','conflict','in-sync','review'];
|
|
|
|
async function init() {
|
|
// E7.2: gate on auth — if the dashboard requires a token and none is stored,
|
|
// show the login card and stop (don't load the dashboard behind it).
|
|
if (!(await checkAuth())) return;
|
|
const tag = document.getElementById('modeTag');
|
|
tag.textContent = STATUS.mode + (STATUS.mode === 'apply' ? '' : ' (safe)');
|
|
if (STATUS.mode === 'apply') tag.classList.add('apply');
|
|
// E1b.5: dev-mode banner — auto-sync is disabled in dev mode (apply-mode floor).
|
|
document.getElementById('devBanner').style.display = STATUS.mode === 'dev' ? '' : 'none';
|
|
INDEX = await apiFetch('/api/index').then(r => r.json());
|
|
const c = INDEX.counts;
|
|
document.getElementById('counts').innerHTML =
|
|
`matched <b>${c.matched}</b> · cc-only <b>${c.ccOnly}</b> · refined-only <b>${c.refinedOnly}</b> · unlinked <b>${c.unlinked}</b>`;
|
|
renderRecPanel();
|
|
render();
|
|
refreshAutosync();
|
|
}
|
|
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
|
function attr(s){ return (s==null?'':String(s)).replace(/[&"]/g,c=>({'&':'&','"':'"'}[c])); }
|
|
function recBadge(r){
|
|
const m = REC[r.recommendation] || REC['review'];
|
|
return `<span class="badge ${m.badge}" title="${attr(m.tip)}">${m.tag || m.label}</span>`;
|
|
}
|
|
function fmtMtime(ms){ if (!ms) return ''; const d = new Date(ms); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }
|
|
function mtimeHint(r){
|
|
if (!r.refinedMtime && !r.ccMtime) return '';
|
|
const parts = [];
|
|
if (r.refinedMtime) parts.push(`ref ${fmtMtime(r.refinedMtime)}`);
|
|
if (r.ccMtime) parts.push(`cc ${fmtMtime(r.ccMtime)}`);
|
|
return `<span class="meta" title="File modification times (hint only — direction is decided by hashes, since cp -r resets mtime)">${esc(parts.join(' · '))}</span>`;
|
|
}
|
|
function renderRecPanel(){
|
|
const el = document.getElementById('recGroups');
|
|
const b = INDEX.byRecommendation;
|
|
const total = REC_ORDER.reduce((n,k)=>n+(b[k]||0),0);
|
|
if (!total){ el.innerHTML = '<div class="rec-group"><span class="label">Everything is in sync. ✨</span></div>'; return; }
|
|
el.innerHTML = REC_ORDER.filter(k => (b[k]||0) > 0).map(k => {
|
|
const m = REC[k];
|
|
const see = `<button data-op="seeRec" data-rec="${k}" title="Filter the table down to just these ${b[k]} file(s) in this bucket.">See ${b[k]}</button>`;
|
|
const run = m.bulk
|
|
? `<button class="primary" data-op="${m.bulk}" title="${attr(m.tip)}">Run ${m.bulk.replace('All',' all')}</button>`
|
|
: `<button disabled title="${attr(m.tip)}">manual</button>`;
|
|
return `<div class="rec-group${REC_FILTER === k ? ' active' : ''}"><span class="num">${b[k]}</span>
|
|
<span class="label">${m.label}<small>${esc(m.tip)}</small></span><span class="btns">${see}${run}</span></div>`;
|
|
}).join('');
|
|
}
|
|
// Filter the table to a single recommendation bucket (set by the "See N" buttons).
|
|
function seeRec(k){
|
|
REC_FILTER = REC_FILTER === k ? null : k; // toggle off if clicked again
|
|
renderRecPanel(); render();
|
|
document.getElementById('matchedTable').scrollIntoView({block:'start'});
|
|
}
|
|
function clearRecFilter(){ REC_FILTER = null; renderRecPanel(); render(); }
|
|
function renderRecChip(){
|
|
const el = document.getElementById('recFilterChip');
|
|
if (!REC_FILTER){ el.style.display='none'; el.innerHTML=''; return; }
|
|
const m = REC[REC_FILTER] || REC['review'];
|
|
const count = (INDEX.byRecommendation[REC_FILTER]||0);
|
|
el.style.display='inline-flex';
|
|
el.innerHTML = `<span class="badge ${m.badge}">${m.label}</span><span class="meta">${count} shown</span><button data-op="clearRecFilter" title="Clear this filter (show all files).">✕</button>`;
|
|
}
|
|
function render(){
|
|
const q = document.getElementById('filter').value.toLowerCase().trim();
|
|
const pass = r => (!q || r.name.toLowerCase().includes(q)) && (!REC_FILTER || r.recommendation === REC_FILTER);
|
|
const m = INDEX.matched.filter(pass);
|
|
const c = INDEX.ccOnly.filter(pass);
|
|
const ro = (INDEX.refinedOnly || []).filter(pass);
|
|
document.querySelector('#matchedTable tbody').innerHTML = m.map(rowHtml).join('');
|
|
document.querySelector('#ccOnlyTable tbody').innerHTML = c.map(rowHtml).join('');
|
|
document.querySelector('#refinedOnlyTable tbody').innerHTML = ro.map(refinedOnlyRowHtml).join('');
|
|
// Reflect filtered counts in the section headers when a filter narrows the view.
|
|
const totM = INDEX.matched.length, totC = INDEX.ccOnly.length, totRO = (INDEX.refinedOnly||[]).length;
|
|
document.getElementById('matchedH2').innerHTML =
|
|
`Matched (refined ⇄ cc)${(q||REC_FILTER) ? ` <span class="cnt">${m.length}/${totM}</span>` : ''}`;
|
|
document.getElementById('ccOnlyH2').innerHTML =
|
|
`cc-only (import candidates)${(q||REC_FILTER) ? ` <span class="cnt">${c.length}/${totC}</span>` : ''}`;
|
|
document.getElementById('refinedOnlyH2').innerHTML =
|
|
`refined-only (no Foundry link)${(q||REC_FILTER) ? ` <span class="cnt">${ro.length}/${totRO}</span>` : totRO ? ` <span class="cnt">${totRO}</span>` : ''}`;
|
|
renderRecChip();
|
|
}
|
|
// The single recommended action button for a row (its primary op), plus a "view" button.
|
|
function recAction(r){
|
|
const n = attr(r.name);
|
|
const m = REC[r.recommendation] || REC['review'];
|
|
switch (r.recommendation){
|
|
case 'import': return `<button class="rec" data-op="import" data-name="${n}" title="${attr(m.tip)}">import</button>`;
|
|
case 'seed': return `<button class="rec" data-op="seed" data-name="${n}" title="${attr(m.tip)}">seed</button>`;
|
|
case 'sync-cc': return `<button class="rec" data-op="sync" data-name="${n}" title="${attr(m.tip)}">sync→cc</button>`;
|
|
case 'repull': return `<button class="rec" data-op="repull" data-name="${n}" title="${attr(m.tip)}">re-pull</button>`;
|
|
case 'conflict':
|
|
case 'review': return `<button data-op="view" data-name="${n}" title="${attr(m.tip)}">review</button>`;
|
|
case 'in-sync': return `<span class="meta" title="${attr(m.tip)}">✓</span>`;
|
|
default: return '';
|
|
}
|
|
}
|
|
function rowHtml(r){
|
|
const cls = SEL === r.name ? 'sel' : '';
|
|
const meta = [r.curatedType && `type:${r.curatedType}`, r.ccType && `cc:${r.ccType}`].filter(Boolean).join(' ');
|
|
return `<tr class="${cls}" data-name="${attr(r.name)}">
|
|
<td class="name">${esc(r.name)}</td>
|
|
<td>${recBadge(r)}</td>
|
|
<td style="color:var(--mut)">${esc(meta)}<br/>${mtimeHint(r)}</td>
|
|
<td class="rowbtns">${recAction(r)} <button data-op="push" data-name="${attr(r.name)}" title="Push this refined note into the LIVE Foundry world via the relay (Foundry keeps running). dry-run shows the diff; uncheck dry-run to apply (writes a .bak first). This updates Foundry — unlike sync→cc, which only writes the local cc.md artifact.">push</button> <button data-op="view" data-name="${attr(r.name)}" title="Open this file in the detail panel: see refined + cc side by side, the seed/sync/re-pull previews, and the Foundry entry.">view</button></td></tr>`;
|
|
}
|
|
// A refined-only note (in the vault but not linked to any Foundry entry). Offer a
|
|
// "link" button that opens the Foundry entry picker; once linked by uuid it matches.
|
|
function refinedOnlyRowHtml(r){
|
|
const cls = SEL === r.name ? 'sel' : '';
|
|
return `<tr class="${cls}" data-name="${attr(r.name)}">
|
|
<td class="name">${esc(r.name)}</td>
|
|
<td>${recBadge(r)}</td>
|
|
<td style="color:var(--mut)">${esc(r.refinedPath || '')}</td>
|
|
<td class="rowbtns"><button class="rec" data-op="link" data-name="${attr(r.name)}" title="Link this note to a Foundry journal entry by name — injects the foundry: identity block (cc_uuid) so it matches. Curation untouched.">link</button> <button data-op="view" data-name="${attr(r.name)}" title="Open this file in the detail panel.">view</button></td></tr>`;
|
|
}
|
|
async function select(name){
|
|
SEL = name; render();
|
|
const d = document.getElementById('detail');
|
|
d.innerHTML = `loading ${esc(name)}…`;
|
|
const f = await apiFetch('/api/file?name=' + encodeURIComponent(name)).then(r => r.json());
|
|
const r = f.row;
|
|
const m = REC[r.recommendation] || REC['review'];
|
|
const parts = [];
|
|
parts.push(`<div class="panel"><h3>${esc(name)} ${recBadge(r)}</h3>
|
|
<pre>${esc(JSON.stringify({name:r.name,status:r.status,recommendation:r.recommendation,refinedChanged:r.refinedChanged,ccChanged:r.ccChanged,curatedType:r.curatedType,ccType:r.ccType,ccId:r.ccId,storedRefinedHash:r.storedRefinedHash,storedCcHash:r.storedCcHash,entry:!!f.entry},null,2))}</pre>
|
|
<p class="meta">${esc(m.tip)}</p></div>`);
|
|
if (r.refinedPath) parts.push(`<div class="panel"><h3>refined note (vault) <button data-op="seed" data-name="${attr(name)}" title="Inject/refresh the foundry: block in this refined note only. Curation untouched.">seed this</button> <button data-op="sync" data-name="${attr(name)}" title="Regenerate cc.md from this refined note and baseline both sides.">sync→cc this</button> <button data-op="repull" data-name="${attr(name)}" title="Regenerate this refined note's body from Foundry, preserving curation.">re-pull this</button> <button data-op="push" data-name="${attr(name)}" title="Push this note into the LIVE Foundry world via the relay (Foundry keeps running). dry-run shows the diff; uncheck dry-run to apply. Updates Foundry itself — sync→cc only writes the local cc.md.">push this</button></h3><pre>${esc(f.refined)}</pre></div>`);
|
|
if (f.cc != null) parts.push(`<div class="panel"><h3>existing cc.md (Foundry export)</h3><pre>${esc(f.cc)}</pre></div>`);
|
|
if (f.seedPreview != null) parts.push(`<div class="panel"><h3>seed preview <small style="font-weight:normal">(foundry: block injected, curation preserved)</small></h3>${diff(f.refined, f.seedPreview)}</div>`);
|
|
if (f.syncRefinedPreview != null && f.refined != null) parts.push(`<div class="panel"><h3>sync preview — refined side <small style="font-weight:normal">(foundry.contentHash refreshed)</small></h3>${diff(f.refined, f.syncRefinedPreview)}</div>`);
|
|
if (f.syncPreview != null && f.cc != null) parts.push(`<div class="panel"><h3>sync preview — cc side <small style="font-weight:normal">(curation flows back; cc_sync_hash written)</small></h3>${diff(f.cc, f.syncPreview)}</div>`);
|
|
if (f.repullPreview != null && f.refined != null) parts.push(`<div class="panel"><h3>re-pull preview <small style="font-weight:normal">(body from Foundry, curation preserved)</small></h3>${diff(f.refined, f.repullPreview)}</div>`);
|
|
if (f.entry) parts.push(`<div class="panel"><h3>Foundry journal entry</h3><pre>${esc(JSON.stringify(f.entry,null,2))}</pre></div>`);
|
|
d.innerHTML = parts.join('\n');
|
|
}
|
|
function diff(a, b){
|
|
const al = (a||'').split('\n'), bl = (b||'').split('\n');
|
|
const aSet = new Set(al), bSet = new Set(bl);
|
|
const lines = [];
|
|
for (const l of al) if (!bSet.has(l)) lines.push(`<span class="diff-del">- ${esc(l)}</span>`);
|
|
for (const l of bl) if (!aSet.has(l)) lines.push(`<span class="diff-add">+ ${esc(l)}</span>`);
|
|
if (!lines.length) return '<pre>(identical)</pre>';
|
|
return '<pre>' + lines.join('\n') + '</pre>';
|
|
}
|
|
let toastT = null;
|
|
function toast(msg){
|
|
let el = document.querySelector('.toast');
|
|
if (el) el.remove();
|
|
el = document.createElement('div'); el.className = 'toast'; el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
clearTimeout(toastT); toastT = setTimeout(()=>el.remove(), 6000);
|
|
}
|
|
async function act(op, names){
|
|
const dryRun = dryEl().checked;
|
|
const body = { op, dryRun };
|
|
if (names) body.names = names;
|
|
toast(`${op} ${dryRun?'(dry-run)':'('+STATUS.mode+')'}…`);
|
|
const r = await apiFetch('/api/action', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
const wrote = (r.written||[]).length, prev = (r.preview||[]).length, skip = (r.skipped||[]).length;
|
|
toast(r.message || `${op}: ${dryRun?prev:wrote} ${dryRun?'would write':'wrote'}${skip?', '+skip+' skipped':''}`);
|
|
// Refresh index so recommendation counts update after an action.
|
|
INDEX = await apiFetch('/api/index').then(r => r.json());
|
|
renderRecPanel(); render();
|
|
if (SEL) select(SEL);
|
|
}
|
|
// Push one refined note into the LIVE Foundry world via the relay (Foundry keeps
|
|
// running). dry-run (default) shows the diff; uncheck dry-run to apply — the server
|
|
// snapshots the live entry to <out>/bak/<name>.<iso>.json first, so it's reversible.
|
|
async function pushRow(name){
|
|
const dryRun = dryEl().checked;
|
|
toast(`push ${name} ${dryRun?'(dry-run)':'(apply)'}…`);
|
|
const r = await apiFetch('/api/push', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, dryRun})}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
if (r.dryRun){
|
|
toast(`[dry-run] push ${name}: diff ready (${Object.keys(r.diff).length} keys)`);
|
|
if (SEL === name) select(name); // refresh detail so the user can re-inspect
|
|
} else {
|
|
toast(`pushed "${r.updatedName || name}" into live Foundry (backup: ${r.backupPath || 'none'})`);
|
|
}
|
|
}
|
|
// Rebuild the cached name↔uuid map via relay /search (zero Foundry downtime).
|
|
async function refreshLive(){
|
|
toast('refresh live index…');
|
|
const r = await apiFetch('/api/refresh', {method:'POST', headers:{'content-type':'application/json'}, body: '{}'}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
toast(`live index refreshed: ${r.pairs} name↔uuid pairs cached`);
|
|
}
|
|
// Auto-sync (Obsidian→Foundry, instant): the server watches the vault and pushes
|
|
// saved notes into live Foundry. Toggle here; poll for the activity log while on.
|
|
async function refreshAutosync(){
|
|
const r = await apiFetch('/api/autosync').then(r=>r.json()).catch(()=>null);
|
|
if (!r) return;
|
|
AUTO = r;
|
|
const btn = document.getElementById('autoSyncBtn');
|
|
// E1b.3: TOCTOU conflict badge — Foundry was edited during a push window;
|
|
// the DM must reconcile (Sync / Re-pull). Surfaced loudly on the button.
|
|
const conflicts = r.conflictCount || 0;
|
|
btn.textContent = `Auto-sync: ${r.enabled ? 'on' : 'off'}${conflicts ? ` ⚠ ${conflicts} conflict${conflicts > 1 ? 's' : ''}` : ''}`;
|
|
btn.classList.toggle('primary', r.enabled);
|
|
btn.classList.toggle('bad', conflicts > 0);
|
|
const panel = document.getElementById('autoSyncPanel');
|
|
panel.style.display = r.enabled ? '' : 'none';
|
|
document.getElementById('autoSyncCounts').textContent =
|
|
r.enabled ? `pushed ${r.pushed} · skipped ${r.skipped} · errors ${r.errors}` : '';
|
|
document.getElementById('autoSyncNote').textContent =
|
|
conflicts > 0
|
|
? `⚠ ${conflicts} TOCTOU conflict${conflicts > 1 ? 's' : ''} — Foundry was edited during a push (the push is live but NOT baselined); use Sync / Re-pull to reconcile`
|
|
: r.enabled && STATUS && STATUS.mode !== 'apply'
|
|
? '(dev mode — baselines land in the --out mirror, not the real vault; run --apply to baseline the real vault)'
|
|
: r.enabled
|
|
? 'Auto-sync is opt-in per session — resets to OFF on restart'
|
|
: '';
|
|
const log = document.getElementById('autoSyncLog');
|
|
log.textContent = r.events && r.events.length
|
|
? r.events.map(e => `${e.time.replace('T',' ').slice(5,19)} ${e.status.padEnd(7)} ${e.name} — ${e.message}`).join('\n')
|
|
: '(no activity yet — save a linked, seeded note in your vault to trigger a push)';
|
|
if (r.enabled && !autoPoll) autoPoll = setInterval(refreshAutosync, 2000);
|
|
if (!r.enabled && autoPoll) { clearInterval(autoPoll); autoPoll = null; }
|
|
// E1b.8: flagsSchemaVersion migration banner (shown once after start, dismissible).
|
|
const migBanner = document.getElementById('migrationBanner');
|
|
if (r.migrationRan && r.migrationCount > 0 && !migrationDismissed) {
|
|
migBanner.textContent = `Migrated ${r.migrationCount} note${r.migrationCount > 1 ? 's' : ''} to flagsSchemaVersion ${r.schemaVersion} (foundry.flagsSchemaVersion stamped; contentHash/ccHash untouched) — click to dismiss`;
|
|
migBanner.style.display = '';
|
|
migBanner.onclick = () => { migrationDismissed = true; migBanner.style.display = 'none'; };
|
|
} else {
|
|
migBanner.style.display = 'none';
|
|
}
|
|
// E1b.4: "Revert last push" button — shown when there's a recent push and
|
|
// the guard is on (revert is meaningless without the per-uuid backups).
|
|
const revertBar = document.getElementById('revertBar');
|
|
const revertBtn = document.getElementById('revertBtn');
|
|
if (r.lastPush && r.enabled) {
|
|
revertBar.style.display = '';
|
|
revertBtn.textContent = `Revert last push: ${r.lastPush.name}`;
|
|
revertBtn.dataset.uuid = r.lastPush.uuid;
|
|
} else {
|
|
revertBar.style.display = 'none';
|
|
}
|
|
}
|
|
async function revertLastPush(){
|
|
const btn = document.getElementById('revertBtn');
|
|
const uuid = btn.dataset.uuid;
|
|
if (!uuid) return;
|
|
const noteName = btn.textContent.replace('Revert last push: ', '');
|
|
if (!confirm(`Revert the last push of "${noteName}"?\n\nThis restores Foundry to the state captured BEFORE the push (a full /update — the one place a full PUT is correct) and re-baselines the note. The note keeps your edit; Foundry reverts.`)) return;
|
|
toast('reverting last push…');
|
|
const r = await apiFetch('/api/autosync/revert', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({uuid})}).then(r=>r.json()).catch(()=>null);
|
|
if (r && r.ok) toast(`reverted — Foundry restored to pre-push state ("${r.restoredName ?? noteName}")`);
|
|
else toast(`revert failed: ${r?.error || 'unknown'}`);
|
|
refreshAutosync();
|
|
}
|
|
async function toggleAutosync(){
|
|
const want = !(AUTO && AUTO.enabled);
|
|
toast(`turning auto-sync ${want ? 'on' : 'off'}…`);
|
|
const r = await apiFetch('/api/autosync', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({enabled: want})}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
toast(`auto-sync ${r.enabled ? 'ON — saving a note pushes it to live Foundry' : 'off'}`);
|
|
refreshAutosync();
|
|
}
|
|
// Push every vault-newer (sync-cc) note into live Foundry in one run. dry-run (default)
|
|
// lists what would be pushed; apply pushes each, backs up the live entry, and baselines
|
|
// the note so a re-run only catches new edits. Replaces scripts/resync.ts.
|
|
async function pushAll(){
|
|
const dryRun = dryEl().checked;
|
|
toast(`push all changed ${dryRun?'(dry-run)':'(apply)'}… this may take a moment`);
|
|
const r = await apiFetch('/api/push-all', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({dryRun})}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
renderPushAllResults(r);
|
|
if (r.dryRun){
|
|
toast(`[dry-run] push all: ${r.wouldPush} note(s) would be pushed into live Foundry`);
|
|
} else {
|
|
toast(`pushed ${r.pushed}/${r.total}${r.failed?', '+r.failed+' failed':''} · baselined ${r.baselined}`);
|
|
INDEX = await apiFetch('/api/index').then(r => r.json());
|
|
renderRecPanel(); render();
|
|
}
|
|
}
|
|
function renderPushAllResults(r){
|
|
const d = document.getElementById('detail');
|
|
const head = r.dryRun
|
|
? `<div class="panel"><h3>Push all — dry-run preview</h3><p class="meta">${r.wouldPush} vault-newer note(s) would be pushed into live Foundry. Uncheck dry-run and click "Push all changed" again to apply.</p></div>`
|
|
: `<div class="panel"><h3>Push all — applied</h3><p class="meta">pushed ${r.pushed}/${r.total}, failed ${r.failed}, baselined ${r.baselined}. Live-entry backups in <code>${STATUS.outDir}/bak</code>. Re-run to retry any failures (baselined notes are skipped).</p></div>`;
|
|
const items = (r.preview || r.results || []).map(it => {
|
|
const stat = r.dryRun
|
|
? `<span class="meta">${it.ccUuid ? esc(it.ccUuid) : '(no foundry link — would be skipped)'}</span>`
|
|
: (it.ok
|
|
? `<span class="badge ok">pushed</span>${it.imageNote && it.imageNote !== 'no portrait field' ? ' · ' + esc(it.imageNote) : ''}`
|
|
: `<span class="badge bad">failed</span> ${esc(it.error || '')}`);
|
|
return `<div class="panel"><h3>${esc(it.name)}</h3><p class="meta">${stat}</p>${it.logs ? `<pre>${esc(it.logs.join('\n'))}</pre>` : ''}</div>`;
|
|
}).join('');
|
|
d.innerHTML = head + items;
|
|
}
|
|
// Link a refined-only note to a Foundry entry: open a picker modal of journal entries,
|
|
// filtered by name. Selecting one POSTs /api/link (injects the foundry: block) and
|
|
// re-indexes so the note leaves refined-only and becomes matched.
|
|
let LINK_ENTRIES = null, LINK_NAME = null;
|
|
async function linkPicker(name){
|
|
LINK_NAME = name;
|
|
if (!LINK_ENTRIES) {
|
|
const r = await apiFetch('/api/entries').then(r => r.json());
|
|
if (r.error) { toast('error: ' + r.error); return; }
|
|
LINK_ENTRIES = r.entries || [];
|
|
}
|
|
const bg = document.createElement('div'); bg.className = 'modal-bg';
|
|
bg.innerHTML = `<div class="modal">
|
|
<h3>Link "${esc(name)}" to a Foundry entry</h3>
|
|
<div class="modal-body">
|
|
<input type="text" id="linkFilter" placeholder="filter by entry name…" oninput="renderLinkList()" style="margin-bottom:8px" />
|
|
<div id="linkList"></div>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(bg);
|
|
bg.addEventListener('click', (e) => { if (e.target === bg) bg.remove(); });
|
|
document.getElementById('linkFilter').focus();
|
|
renderLinkList();
|
|
}
|
|
function renderLinkList(){
|
|
const q = (document.getElementById('linkFilter')?.value || '').toLowerCase().trim();
|
|
const list = document.getElementById('linkList');
|
|
if (!list) return;
|
|
const hits = LINK_ENTRIES.filter(e => !q || e.name.toLowerCase().includes(q)).slice(0, 200);
|
|
list.innerHTML = hits.length ? hits.map(e =>
|
|
`<div class="entry" data-uuid="${attr(e.uuid)}"><span class="en">${esc(e.name)}</span><span class="et">${esc(e.type || '')} · ${esc(e.uuid)}</span></div>`).join('')
|
|
: '<p class="meta">no entries match</p>';
|
|
list.querySelectorAll('.entry').forEach(el => el.onclick = () => doLink(LINK_NAME, el.dataset.uuid));
|
|
}
|
|
async function doLink(name, uuid){
|
|
document.querySelector('.modal-bg')?.remove();
|
|
const dryRun = dryEl().checked;
|
|
toast(`link ${name} ${dryRun?'(dry-run)':'(apply)'}…`);
|
|
const r = await apiFetch('/api/link', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({name, uuid, dryRun})}).then(r=>r.json());
|
|
if (r.error){ toast('error: ' + r.error); return; }
|
|
if (r.dryRun){ toast(`[dry-run] link ${name} -> ${uuid}`); }
|
|
else {
|
|
toast(r.message || `linked ${name}`);
|
|
INDEX = await apiFetch('/api/index').then(r => r.json());
|
|
renderRecPanel(); render();
|
|
}
|
|
}
|
|
init();
|
|
// Re-scan the vault + cc from disk and re-render, preserving the current filter/selection.
|
|
// Called by the "Re-scan" button and automatically when the tab regains focus (so edits
|
|
// made in Obsidian show up without a manual refresh).
|
|
async function refreshIndex(){
|
|
INDEX = await apiFetch('/api/index').then(r => r.json());
|
|
const c = INDEX.counts;
|
|
document.getElementById('counts').innerHTML =
|
|
`matched <b>${c.matched}</b> · cc-only <b>${c.ccOnly}</b> · refined-only <b>${c.refinedOnly}</b> · unlinked <b>${c.unlinked}</b>`;
|
|
renderRecPanel(); render();
|
|
if (SEL) select(SEL);
|
|
}
|
|
// Switching back from Obsidian (or another tab) re-scans automatically.
|
|
window.addEventListener('focus', refreshIndex);
|
|
// Delegated clicks for dynamically-rendered rows/buttons (data-name is HTML-escaped,
|
|
// so names with spaces/quotes can't break the attribute the way inline onclick + JSON could).
|
|
document.querySelector('section.list').addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-op]');
|
|
if (btn) {
|
|
e.stopPropagation();
|
|
const op = btn.dataset.op, name = btn.dataset.name, rec = btn.dataset.rec;
|
|
if (op === 'view') select(name);
|
|
else if (op === 'seeRec') seeRec(rec);
|
|
else if (op === 'clearRecFilter') clearRecFilter();
|
|
else if (op === 'link' && name) linkPicker(name);
|
|
else if (op === 'push' && name) pushRow(name);
|
|
else if (name) act(op, [name]);
|
|
else act(op); // bulk ops (seedAll/syncAll/repullAll/importAll) carry no data-name
|
|
return;
|
|
}
|
|
const tr = e.target.closest('tr[data-name]');
|
|
if (tr) select(tr.dataset.name);
|
|
});
|
|
document.getElementById('detail').addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button[data-op]');
|
|
if (!btn) return;
|
|
const op = btn.dataset.op, name = btn.dataset.name;
|
|
if (op === 'seed' || op === 'sync' || op === 'repull') act(op, [name]);
|
|
else if (op === 'push' && name) pushRow(name);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |