@bitpub/cli 2.1.2 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/browser.js +132 -0
- package/src/commands/sync.js +18 -13
- package/static/console.html +156 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bitpub": "./bin/bitpub.js"
|
package/src/commands/browser.js
CHANGED
|
@@ -21,6 +21,7 @@ const { exec, spawn } = require('child_process');
|
|
|
21
21
|
const { readConfig, BITPUB_DIR } = require('../config');
|
|
22
22
|
const { getSyncedNamespaces, initCache } = require('../db/cache');
|
|
23
23
|
const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
|
|
24
|
+
const { runOneShot: runSyncOneShot } = require('./sync');
|
|
24
25
|
|
|
25
26
|
const Database = require('better-sqlite3');
|
|
26
27
|
const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
|
|
@@ -366,6 +367,128 @@ function handleBridgeRun(req, res, url) {
|
|
|
366
367
|
});
|
|
367
368
|
}
|
|
368
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Resolve the set of namespaces to sync for "sync everything visible
|
|
372
|
+
* to the current user." This is the same shape an attentive user would
|
|
373
|
+
* type by hand: their private root + every joined group.
|
|
374
|
+
*
|
|
375
|
+
* Returns an array of `{ pattern, label, config }` triples. The per-
|
|
376
|
+
* scope `config` is the user's primary config with `api_key` swapped
|
|
377
|
+
* out when a group entry pins its own key (multi-backend memberships).
|
|
378
|
+
*/
|
|
379
|
+
function resolveSyncTargets(baseConfig) {
|
|
380
|
+
const targets = [];
|
|
381
|
+
if (baseConfig && baseConfig.owner) {
|
|
382
|
+
targets.push({
|
|
383
|
+
pattern: `bitpub://private:${baseConfig.owner}/**`,
|
|
384
|
+
label: `private:${baseConfig.owner}`,
|
|
385
|
+
config: baseConfig,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const groups = Array.isArray(baseConfig && baseConfig.groups) ? baseConfig.groups : [];
|
|
389
|
+
for (const g of groups) {
|
|
390
|
+
if (!g || !g.slug) continue;
|
|
391
|
+
targets.push({
|
|
392
|
+
pattern: `bitpub://group:${g.slug}/**`,
|
|
393
|
+
label: `group:${g.slug}`,
|
|
394
|
+
// Per-group key/URL is only relevant on multi-backend setups; for
|
|
395
|
+
// the common single-backend case these fields equal the primary
|
|
396
|
+
// config and the fallback is a no-op.
|
|
397
|
+
config: {
|
|
398
|
+
...baseConfig,
|
|
399
|
+
api_key: g.key || baseConfig.api_key,
|
|
400
|
+
api_url: g.api_url || baseConfig.api_url,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return targets;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Stream `bitpub sync` across every scope visible to the user as SSE.
|
|
409
|
+
*
|
|
410
|
+
* Wire format:
|
|
411
|
+
* event: started data: { targets: [{label, pattern}, ...] }
|
|
412
|
+
* event: scope data: { label, pattern, status: 'syncing' }
|
|
413
|
+
* event: scope data: { label, pattern, status: 'done', count, hitLimit }
|
|
414
|
+
* event: scope data: { label, pattern, status: 'error', message }
|
|
415
|
+
* event: done data: { total, scopes } ← terminal; connection closes
|
|
416
|
+
*
|
|
417
|
+
* This is browser-chrome plumbing, not an app function — it calls
|
|
418
|
+
* sync.js#runOneShot directly inside this process rather than spawning
|
|
419
|
+
* `bitpub sync` as a subprocess via the script.run primitive. Reasons:
|
|
420
|
+
* • No shell-out cost, no quoting hazard, no env propagation issues.
|
|
421
|
+
* • Per-scope progress is structured (event stream), not parsed stdout.
|
|
422
|
+
* • Manifests are for user-published apps; the Sync button is part of
|
|
423
|
+
* the browser shell and shouldn't go through that machinery.
|
|
424
|
+
*/
|
|
425
|
+
function handleBridgeSync(req, res) {
|
|
426
|
+
res.statusCode = 200;
|
|
427
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
428
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
429
|
+
res.setHeader('Connection', 'keep-alive');
|
|
430
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
431
|
+
|
|
432
|
+
function send(event, payload) {
|
|
433
|
+
if (res.writableEnded) return;
|
|
434
|
+
res.write(`event: ${event}\n`);
|
|
435
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const cfg = readConfig();
|
|
439
|
+
if (!cfg || !cfg.owner) {
|
|
440
|
+
send('error', { message: 'No bitpub identity configured. Run `bitpub setup` first.' });
|
|
441
|
+
send('done', { total: 0, scopes: 0 });
|
|
442
|
+
res.end();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const targets = resolveSyncTargets(cfg);
|
|
447
|
+
send('started', { targets: targets.map(t => ({ label: t.label, pattern: t.pattern })) });
|
|
448
|
+
|
|
449
|
+
let cancelled = false;
|
|
450
|
+
req.on('close', () => { cancelled = true; });
|
|
451
|
+
|
|
452
|
+
// Sync scopes sequentially so the progress events tell a single story
|
|
453
|
+
// and we don't slam the same backend with N concurrent pulls.
|
|
454
|
+
(async () => {
|
|
455
|
+
let total = 0;
|
|
456
|
+
let okScopes = 0;
|
|
457
|
+
for (const t of targets) {
|
|
458
|
+
if (cancelled) return;
|
|
459
|
+
send('scope', { label: t.label, pattern: t.pattern, status: 'syncing' });
|
|
460
|
+
try {
|
|
461
|
+
const r = await runSyncOneShot(
|
|
462
|
+
{ pattern: t.pattern, label: t.label, limit: 500, includeDeleted: false, quiet: true },
|
|
463
|
+
t.config
|
|
464
|
+
);
|
|
465
|
+
total += r.count;
|
|
466
|
+
okScopes += 1;
|
|
467
|
+
send('scope', {
|
|
468
|
+
label: t.label,
|
|
469
|
+
pattern: t.pattern,
|
|
470
|
+
status: 'done',
|
|
471
|
+
count: r.count,
|
|
472
|
+
hitLimit: !!r.hitLimit,
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
send('scope', {
|
|
476
|
+
label: t.label,
|
|
477
|
+
pattern: t.pattern,
|
|
478
|
+
status: 'error',
|
|
479
|
+
message: err && err.message ? err.message : String(err),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
send('done', { total, scopes: okScopes });
|
|
484
|
+
res.end();
|
|
485
|
+
})().catch((err) => {
|
|
486
|
+
send('error', { message: err && err.message ? err.message : String(err) });
|
|
487
|
+
send('done', { total: 0, scopes: 0 });
|
|
488
|
+
res.end();
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
369
492
|
function openInBrowser(url) {
|
|
370
493
|
const cmd = process.platform === 'darwin' ? 'open' :
|
|
371
494
|
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -469,6 +592,15 @@ function startBrowserServer(opts = {}) {
|
|
|
469
592
|
return;
|
|
470
593
|
}
|
|
471
594
|
|
|
595
|
+
// Bridge: sync every namespace visible to this user (private +
|
|
596
|
+
// each joined group) and stream per-scope progress as SSE. This
|
|
597
|
+
// backs the sidebar "Sync" button — it's browser chrome, not an
|
|
598
|
+
// app function, and so doesn't go through the manifest system.
|
|
599
|
+
if (url.pathname === '/bridge/sync') {
|
|
600
|
+
handleBridgeSync(req, res);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
472
604
|
// Bridge: deterministic namespace read. Apps call this to fetch
|
|
473
605
|
// any slices that match an HCU pattern (exact or prefix-with-/*).
|
|
474
606
|
// No LLM, no MCP, no auth in the loop — this is the read path
|
package/src/commands/sync.js
CHANGED
|
@@ -60,7 +60,7 @@ function resolveSyncPattern(input, config) {
|
|
|
60
60
|
return { pattern: active.namespace + resolved, label: input };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
|
|
63
|
+
async function runOneShot({ pattern, label, limit, includeDeleted, quiet }, config) {
|
|
64
64
|
const api = createApiClient(config);
|
|
65
65
|
const limitNum = parseInt(limit, 10) || 500;
|
|
66
66
|
|
|
@@ -70,19 +70,24 @@ async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
|
|
|
70
70
|
for (const slice of slices) upsertSlice(slice);
|
|
71
71
|
recordNamespaceSync(pattern, slices.length);
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
const hitLimit = slices.length >= Math.min(limitNum, 500);
|
|
74
|
+
|
|
75
|
+
if (!quiet) {
|
|
76
|
+
console.log(`Synced ${slices.length} slice(s) from ${label}`);
|
|
77
|
+
// Surface page-cap warning so the user knows to narrow their pattern.
|
|
78
|
+
if (hitLimit) {
|
|
79
|
+
console.log(
|
|
80
|
+
`\n Hit the page limit (${slices.length} rows). Server caps single sync at 500.` +
|
|
81
|
+
` If you expect more, sync sub-namespaces individually:`
|
|
82
|
+
);
|
|
83
|
+
const base = pattern.replace(/\/\*+$/, '');
|
|
84
|
+
console.log(` bitpub sync '${base}/Projects/**'`);
|
|
85
|
+
console.log(` bitpub sync '${base}/Memory/**'`);
|
|
86
|
+
console.log(` bitpub sync '${base}/Sessions/**'`);
|
|
87
|
+
}
|
|
85
88
|
}
|
|
89
|
+
|
|
90
|
+
return { count: slices.length, hitLimit, pattern, label };
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
function runWatch({ pattern, label }, config) {
|
package/static/console.html
CHANGED
|
@@ -217,9 +217,27 @@ svg { flex-shrink: 0; }
|
|
|
217
217
|
#main:hover::-webkit-scrollbar-thumb { background: var(--border); background-clip: content-box; }
|
|
218
218
|
|
|
219
219
|
/* ── Sidebar / Tree ─────────────────────────────────── */
|
|
220
|
-
.sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; }
|
|
220
|
+
.sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
221
221
|
.sidebar-header .clear-btn { font-size: 10.5px; color: var(--text-subtle); cursor: pointer; font-weight: 500; letter-spacing: 0; text-transform: none; }
|
|
222
222
|
.sidebar-header .clear-btn:hover { color: var(--accent); }
|
|
223
|
+
|
|
224
|
+
/* Sync button — icon-only, ghost style, lives in the sidebar header.
|
|
225
|
+
Spins while a /bridge/sync request is in flight; pulses green for a
|
|
226
|
+
moment after a successful sync so the user gets feedback even if no
|
|
227
|
+
new slices landed. */
|
|
228
|
+
.sync-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border: 0; background: transparent; color: var(--text-subtle); cursor: pointer; border-radius: var(--radius-sm); font: inherit; font-size: 10.5px; font-weight: 500; text-transform: none; letter-spacing: 0; }
|
|
229
|
+
.sync-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
|
230
|
+
.sync-btn:disabled { opacity: .6; cursor: default; }
|
|
231
|
+
.sync-btn svg { width: 12px; height: 12px; transition: transform .2s ease-out; }
|
|
232
|
+
.sync-btn.is-syncing svg { animation: bp-spin 1s linear infinite; }
|
|
233
|
+
.sync-btn.is-done { color: #10b981; }
|
|
234
|
+
@keyframes bp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
235
|
+
.sync-status { padding: 4px 14px 8px; font-size: 11px; color: var(--text-subtle); line-height: 1.4; max-height: 120px; overflow-y: auto; }
|
|
236
|
+
.sync-status.hidden { display: none; }
|
|
237
|
+
.sync-status .row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; }
|
|
238
|
+
.sync-status .row .label { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
239
|
+
.sync-status .row .count { color: var(--text-subtle); flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
|
240
|
+
.sync-status .row.error .count { color: var(--scope-group); }
|
|
223
241
|
.tree-wrap { flex: 1; overflow-y: auto; padding: 0 6px 12px; }
|
|
224
242
|
.tree-wrap::-webkit-scrollbar { width: 8px; }
|
|
225
243
|
.tree-wrap::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
@@ -972,8 +990,15 @@ svg { flex-shrink: 0; }
|
|
|
972
990
|
<aside id="sidebar">
|
|
973
991
|
<div class="sidebar-header">
|
|
974
992
|
<span>Namespaces</span>
|
|
975
|
-
<span
|
|
993
|
+
<span style="display:inline-flex; align-items:center; gap:6px;">
|
|
994
|
+
<span id="clear-filter-btn" class="clear-btn hidden" onclick="clearFilter()">Clear</span>
|
|
995
|
+
<button id="sync-btn" class="sync-btn" type="button" onclick="runFullSync()" title="Sync all namespaces from the cloud into your local cache">
|
|
996
|
+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 3a5 5 0 0 1 4.546 2.914.5.5 0 0 0 .908-.418A6 6 0 1 0 2 8a.5.5 0 1 0 1 0 5 5 0 0 1 5-5Z"/><path d="M8 13a5 5 0 0 1-4.546-2.914.5.5 0 0 0-.908.418A6 6 0 1 0 14 8a.5.5 0 1 0-1 0 5 5 0 0 1-5 5Z"/><path d="M11.5 1.5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1 0-1H11V2a.5.5 0 0 1 .5-.5ZM4.5 10.5a.5.5 0 0 1 .5.5v2.5h2.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 .5-.5Z"/></svg>
|
|
997
|
+
<span class="sync-btn-label">Sync</span>
|
|
998
|
+
</button>
|
|
999
|
+
</span>
|
|
976
1000
|
</div>
|
|
1001
|
+
<div id="sync-status" class="sync-status hidden"></div>
|
|
977
1002
|
<div id="tree" class="tree-wrap"></div>
|
|
978
1003
|
</aside>
|
|
979
1004
|
<main id="main"></main>
|
|
@@ -1456,6 +1481,135 @@ function clearFilter() {
|
|
|
1456
1481
|
renderAll();
|
|
1457
1482
|
}
|
|
1458
1483
|
|
|
1484
|
+
/* ── Sidebar Sync button ────────────────────────────────
|
|
1485
|
+
*
|
|
1486
|
+
* Streams /bridge/sync (SSE) and renders per-scope progress under the
|
|
1487
|
+
* header. On `done`, force a fresh /api/data poll so the tree picks up
|
|
1488
|
+
* any new arrivals immediately (without waiting for the 30s tick).
|
|
1489
|
+
*
|
|
1490
|
+
* The button is browser chrome — not an app function — so it talks
|
|
1491
|
+
* directly to /bridge/sync rather than going through window.bitpub.run
|
|
1492
|
+
* and the manifest dispatcher.
|
|
1493
|
+
*
|
|
1494
|
+
* Reentrancy: a second click while a sync is in flight is a no-op
|
|
1495
|
+
* (we disable the button), so we don't need to track an EventSource
|
|
1496
|
+
* handle for cancellation.
|
|
1497
|
+
*/
|
|
1498
|
+
async function runFullSync() {
|
|
1499
|
+
const btn = $('sync-btn');
|
|
1500
|
+
const status = $('sync-status');
|
|
1501
|
+
if (!btn || btn.disabled) return;
|
|
1502
|
+
|
|
1503
|
+
btn.disabled = true;
|
|
1504
|
+
btn.classList.add('is-syncing');
|
|
1505
|
+
btn.classList.remove('is-done');
|
|
1506
|
+
status.classList.remove('hidden');
|
|
1507
|
+
status.innerHTML = '';
|
|
1508
|
+
|
|
1509
|
+
// Track rows so 'syncing' → 'done' overwrites in place rather than
|
|
1510
|
+
// appending a second line per scope.
|
|
1511
|
+
const rows = new Map();
|
|
1512
|
+
|
|
1513
|
+
function setRow(label, pattern, statusText, klass) {
|
|
1514
|
+
let row = rows.get(label);
|
|
1515
|
+
if (!row) {
|
|
1516
|
+
row = document.createElement('div');
|
|
1517
|
+
row.className = 'row';
|
|
1518
|
+
const a = document.createElement('span'); a.className = 'label';
|
|
1519
|
+
const b = document.createElement('span'); b.className = 'count';
|
|
1520
|
+
row.append(a, b);
|
|
1521
|
+
status.appendChild(row);
|
|
1522
|
+
rows.set(label, row);
|
|
1523
|
+
}
|
|
1524
|
+
row.className = 'row' + (klass ? ' ' + klass : '');
|
|
1525
|
+
row.title = pattern;
|
|
1526
|
+
row.children[0].textContent = label;
|
|
1527
|
+
row.children[1].textContent = statusText;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const base = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
|
|
1531
|
+
const es = new EventSource(base + 'bridge/sync');
|
|
1532
|
+
|
|
1533
|
+
function cleanup() {
|
|
1534
|
+
es.close();
|
|
1535
|
+
btn.disabled = false;
|
|
1536
|
+
btn.classList.remove('is-syncing');
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
es.addEventListener('started', (e) => {
|
|
1540
|
+
let payload = {};
|
|
1541
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1542
|
+
for (const t of (payload.targets || [])) setRow(t.label, t.pattern, 'queued', '');
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
es.addEventListener('scope', (e) => {
|
|
1546
|
+
let payload = {};
|
|
1547
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1548
|
+
if (payload.status === 'syncing') {
|
|
1549
|
+
setRow(payload.label, payload.pattern, 'syncing…', '');
|
|
1550
|
+
} else if (payload.status === 'done') {
|
|
1551
|
+
const cnt = (payload.count ?? 0) + ' slices';
|
|
1552
|
+
const suffix = payload.hitLimit ? ' (capped)' : '';
|
|
1553
|
+
setRow(payload.label, payload.pattern, cnt + suffix, '');
|
|
1554
|
+
} else if (payload.status === 'error') {
|
|
1555
|
+
setRow(payload.label, payload.pattern, 'error', 'error');
|
|
1556
|
+
console.error('[sync]', payload.label, payload.message);
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
es.addEventListener('error', (e) => {
|
|
1561
|
+
// Server-sent `event: error` (CLI-reported); EventSource also fires
|
|
1562
|
+
// 'error' on connection drops with no payload. Treat both the same.
|
|
1563
|
+
let payload = {};
|
|
1564
|
+
if (e && e.data) { try { payload = JSON.parse(e.data); } catch {} }
|
|
1565
|
+
if (payload.message) {
|
|
1566
|
+
const row = document.createElement('div');
|
|
1567
|
+
row.className = 'row error';
|
|
1568
|
+
row.innerHTML = '<span class="label">sync failed</span><span class="count"></span>';
|
|
1569
|
+
row.children[1].textContent = payload.message.slice(0, 60);
|
|
1570
|
+
status.appendChild(row);
|
|
1571
|
+
}
|
|
1572
|
+
// Don't cleanup here — wait for `done`, or for the connection to
|
|
1573
|
+
// close on its own. Real errors will be followed by `done`.
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
es.addEventListener('done', async (e) => {
|
|
1577
|
+
let payload = {};
|
|
1578
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1579
|
+
cleanup();
|
|
1580
|
+
btn.classList.add('is-done');
|
|
1581
|
+
setTimeout(() => btn.classList.remove('is-done'), 2000);
|
|
1582
|
+
|
|
1583
|
+
// Force-refresh the data so any newly-synced slices show up in
|
|
1584
|
+
// the tree immediately instead of on the next 30s poll tick.
|
|
1585
|
+
const data = await fetchData({ silent: true });
|
|
1586
|
+
if (data) {
|
|
1587
|
+
const arrivals = new Set();
|
|
1588
|
+
const oldKey = new Set(S.slices.map(s => s.hcu + '|' + (s.last_synced || '')));
|
|
1589
|
+
const nextSlices = data.slices.map(parseSlice);
|
|
1590
|
+
for (const s of nextSlices) {
|
|
1591
|
+
const k = s.hcu + '|' + (s.last_synced || '');
|
|
1592
|
+
if (!oldKey.has(k)) arrivals.add(s.hcu);
|
|
1593
|
+
}
|
|
1594
|
+
S.slices = nextSlices;
|
|
1595
|
+
S.tree = buildTree(S.slices);
|
|
1596
|
+
S.stats = computeStats(S.slices);
|
|
1597
|
+
S.lastUpdated = maxTs(S.slices);
|
|
1598
|
+
S.newArrivals = arrivals;
|
|
1599
|
+
renderAll();
|
|
1600
|
+
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Auto-hide the status panel after a beat if everything succeeded.
|
|
1604
|
+
const anyErrors = Array.from(rows.values()).some(r => r.classList.contains('error'));
|
|
1605
|
+
if (!anyErrors) {
|
|
1606
|
+
setTimeout(() => {
|
|
1607
|
+
if (!btn.classList.contains('is-syncing')) status.classList.add('hidden');
|
|
1608
|
+
}, 4000);
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1459
1613
|
/* ══════════════════════════════════════════════════════
|
|
1460
1614
|
Top-level render
|
|
1461
1615
|
══════════════════════════════════════════════════════ */
|