Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+606
View File
@@ -0,0 +1,606 @@
// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40
import { localDate } from '../../shared/time.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, createLabel } from '../data/labels.js';
import { getPref, setPref } from '../data/preferences.js';
import { walletTxEventMap, onchainAsset, syncAddressLinks } from '../data/tx-links.js';
import { sourceBalance, accountOnchainBalance } from '../data/wallet-addresses.js';
import { listWalletSources } from '../data/wallet-sources.js';
import { listSourceTxs, setTxLabels, labelChannelTxs } from '../data/wallet-txs.js';
import { syncTxProposals } from '../data/proposals.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtInt, fmtUnit, val } from '../ui/format.js';
import { matchDot, openMatchPopover } from '../ui/match-popover.js';
import { copyButton } from '../ui/copy-button.js';
import { CHEVRON, ARROW_DOWN, ARROW_UP } from '../ui/icons.js';
import { isOnchainSource, scanSource } from '../onchain.js';
import { loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js';
import { originHtml } from '../ui/price-origin.js';
import { labelComboMarkup, labelComboMount } from '../ui/label-combobox.js';
import { resolveSelection } from '../ui/filter.js';
import { volgMasonry } from '../ui/masonry.js';
import { latestScanLog } from '../data/scan-logs.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { holdAttention } from '../ui/attention-ring.js';
import { sourceBadgeText } from '../ui/source-badge.js';
import { isLightningSource, scanLightningSource, lightningAccountSats, countMovements } from '../lightning/lightning-scan.js';
import { memoLine } from '../lightning/memo-line.js';
const ACCOUNTS_HASH = targetHash(TARGETS.ACCOUNTS);
const trunc = s => (s && s.length > 18) ? `${s.slice(0, 8)}…${s.slice(-6)}` : (s || '');
let selectedAccounts = null;
let eventMap = new Map();
let btc = null;
let shownTxIds = new Set();
let newTxIds = new Set();
let scanBaseline = null;
const FILTER_SCOPE = 'table.tracking';
const FILTER_KEY = 'accounts';
function saveFilter() {
setPref(FILTER_SCOPE, FILTER_KEY, [...selectedAccounts]).catch(() => { });
}
const COLLAPSE_KEY = 'collapsed';
let collapsedSet = null;
let stopMasonry = null;
async function loadCollapsed() {
const ids = await getPref(FILTER_SCOPE, COLLAPSE_KEY, null);
return new Set(Array.isArray(ids) ? ids : []);
}
function saveCollapsed() {
setPref(FILTER_SCOPE, COLLAPSE_KEY, [...collapsedSet]).catch(() => { });
}
export const title = () => t('tracking.title');
export const headerInColumn = true;
function btcAsset() {
return btc;
}
async function accountsWithSources() {
const accounts = await listAccounts();
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.filter((a, i) => bronnen[i].length > 0);
}
function fmtDate(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' });
}
function fmtDateLong(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'long', year: 'numeric' });
}
function amt(amount) {
const a = btcAsset();
return val(a ? fmtUnit(amount, a) : fmtInt(amount));
}
function fiatSub(amount) {
const f = fiatValue(amount, btcAsset());
return f ? val(f) + originHtml(fiatValueOrigin(btcAsset()), btcAsset()) : '';
}
function amountHtml(amount, numClass = 't-num-md') {
return `<div class="${numClass}">${amt(amount)}</div>
<div class="t-muted-cell po-host">${fiatSub(amount)}</div>`;
}
function asideHtml(items) {
const rows = items.map(i => `
<button type="button" class="trk-acct${selectedAccounts.has(i.id) ? ' is-on' : ''}" data-acct="${i.id}"
aria-pressed="${selectedAccounts.has(i.id)}" title="${esc(i.label)}">
<span class="trk-acct-name">${esc(i.label)}</span>
<span class="trk-acct-line">
<span class="trk-acct-sub">${fmtInt(i.wallets)} ${i.wallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')}</span>
<span class="trk-acct-amt" data-acct-amt="${i.id}">…</span>
</span>
</button>`).join('');
return `<aside class="page-aside trk-aside">
<div class="trk-block trk-total">
<div class="t-label">${t('tracking.accountTotal')}</div>
<div class="trk-total-amt">
<div class="t-num-lg" data-trk="total">…</div>
<div class="t-muted-cell" data-trk="fiat"></div>
</div>
<div class="trk-stats">
${stat(t('tracking.statWallets'), 'wallets')}
${stat(t('tracking.statTxs'), 'txs')}
${stat(t('tracking.statLinked'), 'linked')}
</div>
</div>
<div class="trk-block trk-accounts">
<div class="trk-acct-list">${rows}</div>
<div class="trk-acct-foot">
<span class="t-muted-cell trk-selection" data-trk="selection"></span>
<button type="button" class="trk-act" data-acct-all>${t('filter.none')}</button>
</div>
</div>
<div class="trk-block trk-scan">
<button class="btn btn-primary btn-sm trk-scan-btn" id="scan-all">${t('tracking.scanAll')}</button>
<div class="t-muted-cell trk-scan-status" data-trk="scanstatus"></div>
<ul class="trk-scan-log" data-trk="scanlog"></ul>
<div class="t-muted-cell trk-scan-when" data-trk="scanned"></div>
</div>
</aside>`;
}
function stat(label, sleutel) {
return `<div class="trk-stat"><div class="t-muted-cell">${label}</div><div class="t-value" data-trk="${sleutel}">…</div></div>`;
}
export async function render(el) {
btc = await onchainAsset();
const accts = await accountsWithSources();
if (accts.length === 0) {
el.innerHTML = `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ emptyStateHtml(t('tracking.noAccountsWithSources'), {
href: ACCOUNTS_HASH, linkText: t('tracking.manageAccounts'),
});
return;
}
const wallets = await Promise.all(accts.map(a => accountWallets(a.id)));
const items = accts.map((a, i) => ({ id: a.id, label: a.name, wallets: wallets[i].length }));
if (!selectedAccounts)
selectedAccounts = resolveSelection({ saved: await getPref(FILTER_SCOPE, FILTER_KEY, null), items });
if (!collapsedSet) collapsedSet = await loadCollapsed();
newTxIds = new Set();
scanBaseline = null;
el.innerHTML = `<div class="page-layout">${asideHtml(items)}`
+ `<div class="page-body">`
+ `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ `<div id="acc-body"></div></div></div>`;
bindAside(el, items);
renderBody(el);
loadFiat().then(() => renderBody(el)).catch(() => {});
}
function bindAside(el, items) {
const aside = el.querySelector('.trk-aside');
const alle = aside.querySelector('[data-acct-all]');
const syncAct = () => {
const allOn = items.every(i => selectedAccounts.has(i.id));
alle.textContent = allOn ? t('filter.none') : t('filter.all');
};
const zet = (sel) => {
selectedAccounts = sel;
for (const b of aside.querySelectorAll('[data-acct]')) {
const on = selectedAccounts.has(Number(b.dataset.acct));
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
}
syncAct();
saveFilter();
renderBody(el);
};
syncAct();
alle.onclick = () => zet(items.every(i => selectedAccounts.has(i.id))
? new Set() : new Set(items.map(i => i.id)));
aside.onclick = (e) => {
const b = e.target.closest('[data-acct]');
if (!b) return;
const id = Number(b.dataset.acct);
const next = new Set(selectedAccounts);
if (next.has(id)) next.delete(id); else next.add(id);
zet(next);
};
el.querySelector('#scan-all').onclick = () => scanAll(el);
}
async function sourcesOf(accounts) {
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.flatMap((a, i) => bronnen[i].map(s => ({ ...s, _account: a })));
}
async function selectedSources() {
return sourcesOf((await accountsWithSources()).filter(a => selectedAccounts.has(a.id)));
}
async function allSources() {
return sourcesOf(await accountsWithSources());
}
function groupWallets(sources) {
const byKey = new Map();
for (const s of sources) {
const named = (s.name || '').trim();
const key = named ? `a${s._account.id}:${named}` : `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { account: s._account, name: named, sources: [] });
byKey.get(key).sources.push(s);
}
const groups = [...byKey.values()];
for (const g of groups) g.id = g.sources[0].id;
return groups;
}
const allWallets = async () => groupWallets(await allSources());
const groupedWallets = async () => groupWallets(await selectedSources());
function sourceBadge(s) {
const { label, title } = sourceBadgeText(s);
return `<span class="badge badge-neutral"${title ? ` title="${esc(title)}"` : ''}>${esc(label)}</span>`;
}
async function accountWallets(accountId) {
const byKey = new Map();
for (const s of await listWalletSources(accountId)) {
const named = (s.name || '').trim();
const key = named || `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { name: named, sources: [] });
byKey.get(key).sources.push(s);
}
return [...byKey.values()];
}
async function renderBody(el) {
const body = el.querySelector('#acc-body');
if (!body) return;
eventMap = await walletTxEventMap();
const wallets = await allWallets();
const perWallet = await Promise.all(wallets.map(g => walletTxs(g.sources.map(s => s.id))));
const ids = perWallet.flat().map(tx => tx.id);
if (scanBaseline) {
for (const id of ids) if (!scanBaseline.has(id)) newTxIds.add(id);
scanBaseline = null;
}
shownTxIds = new Set(ids);
const shown =wallets.map((g, i) => [g, perWallet[i]]).filter(([g]) => selectedAccounts.has(g.account.id));
body.innerHTML = `<div class="oca-grid">${
(await Promise.all(shown.map(([g, txs]) => walletBlock(g, txs)))).join('')}</div>`;
await vulKolom(el, wallets, perWallet);
if (stopMasonry) stopMasonry();
stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source');
for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card);
const btc = await btcAsset();
const openMatch = dot => openMatchPopover(dot, {
kind: 'tx', id: Number(dot.dataset.matchId),
assetId: btc ? btc.id : null,
onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)),
});
body.onclick = e => {
const collapseBtn = e.target.closest('[data-collapse]');
if (collapseBtn) {
const id = Number(collapseBtn.dataset.collapse);
const card = collapseBtn.closest('.oca-source');
const nowCollapsed = card.classList.toggle('is-collapsed');
if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id);
collapseBtn.classList.toggle('is-open', !nowCollapsed);
saveCollapsed();
return;
}
if (e.target.closest('summary') &&
e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) {
e.preventDefault();
}
const dot = e.target.closest('[data-match-trigger]');
if (dot) { openMatch(dot); return; }
const trigger = e.target.closest('[data-edit-label]');
if (trigger) openLabelEditor(el, trigger);
};
body.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const dot = e.target.closest('[data-match-trigger]');
if (dot) { e.preventDefault(); openMatch(dot); }
};
}
async function vulKolom(el, wallets, perWallet) {
const zet = (sleutel, html) => {
const e = el.querySelector(`[data-trk="${sleutel}"]`);
if (e) e.innerHTML = html;
};
const alle = await accountsWithSources();
const bronnen = await sourcesOf(alle);
const bronnenVan = id => bronnen.filter(s => s._account.id === id);
const saldi = new Map();
for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id)));
for (const a of alle) {
const cel = el.querySelector(`[data-acct-amt="${a.id}"]`);
if (cel) cel.innerHTML = amt(saldi.get(a.id));
}
const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('total', amt(total));
zet('fiat', fiatSub(total));
const { total: nTx, linked: nLinked } =
countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0);
zet('wallets', fmtInt(wallets.length));
zet('txs', fmtInt(nTx));
zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-');
const gekozen = alle.filter(a => selectedAccounts.has(a.id));
const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('selection', gekozen.length === alle.length ? ''
: `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`);
const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id)));
const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)]
.filter(Boolean).sort().at(-1)).filter(Boolean);
zet('scanned', (!gekozen.length || tijden.length < gekozen.length)
? t('tracking.scanNever')
: `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`);
}
async function walletBlock(g, txs) {
const name = g.name || t('tracking.sourceFallback');
const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id)));
const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources);
const badges = g.sources.map(sourceBadge).join('');
const collapsed = collapsedSet.has(g.id);
const isNew = txs.some(tx => newTxIds.has(tx.id));
return `<div class="oca-source card${collapsed ? ' is-collapsed' : ''}"${isNew ? ' data-new' : ''}>
<button type="button" class="chev-btn oca-collapse${collapsed ? '' : ' is-open'}" data-collapse="${g.id}" title="${esc(t('tracking.collapseToggle'))}" aria-label="${esc(t('tracking.collapseToggle'))}">${CHEVRON}</button>
<div class="oca-source-head">
<div class="oca-source-meta">
<div class="oca-source-name">${esc(name)}</div>
<div class="oca-source-acct">${esc(g.account ? g.account.name : '')}</div>
</div>
<div class="oca-wallet-tags">${badges}</div>
<div class="oca-source-total">${amountHtml(sats)}</div>
</div>
<div class="oca-movements">${walletMovements(txs)}</div>
</div>`;
}
async function walletTxs(sourceIds) {
const seen = new Map();
for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx);
return [...seen.values()];
}
function walletMovements(txs) {
if (txs.length === 0) {
return `<div class="mv-group"><div class="mv-card"><div class="mv-empty t-body-sm t-muted">${t('tracking.noTxs')}</div></div></div>`;
}
const groups = new Map();
for (const tx of txs) {
const key = tx.blockTime ? localDate(tx.blockTime) : '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(tx);
}
const keys = [...groups.keys()].sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a < b ? 1 : (a > b ? -1 : 0);
});
return keys.map(k => {
const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k);
return `<div class="mv-group">
<div class="mv-card"><div class="mv-date">${label}</div>${dayRows(groups.get(k))}</div>
</div>`;
}).join('');
}
function dayRows(txs) {
const byEvent = new Map();
for (const tx of txs) {
for (const ev of (eventMap.get(tx.id) || [])) {
if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] });
byEvent.get(ev.eventId).list.push(tx);
}
}
const inGroup = new Set();
for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id);
const emitted = new Set();
return txs.map(tx => {
const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2);
if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx);
return groups.map(ev => {
if (emitted.has(ev.eventId)) return '';
emitted.add(ev.eventId);
const g = byEvent.get(ev.eventId);
return eventGroupRow(g.ev, g.list);
}).join('');
}).join('');
}
function eventGroupRow(ev, list) {
const out = ev.fromAccountId === list[0].accountId;
const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn');
const arrow = out ? ARROW_UP : ARROW_DOWN;
const typeLabel = t('enums.event_type.' + ev.type);
const other = out ? ev.to_name : ev.from_name;
const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel;
const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId);
const shown = allSame ? list[0].label_name : desc;
const idsAttr = esc(JSON.stringify(list.map(x => x.id)));
const feeBlock = ev.unitFee
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(ev.unitFee)}</div>
<div class="t-muted-cell">${fiatSub(ev.unitFee)}</div>
</div>`
: '';
return `<details class="mv-grp">
<summary class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${list[0].id}">
<span class="mv-chev">›</span>
<span class="mv-label" data-edit-label data-tx-ids="${idsAttr}" data-label-id="${allSame ? list[0].labelId : ''}" title="${esc(`${desc} · ${list.length} ${t('tracking.txCount')}`)}">${esc(shown)}</span>
${matchDot({ matched: true, method: ev.method, kind: 'tx', id: list[0].id })}
</div>
<div class="mv-amounts">${feeBlock}
<div class="mv-amt"><div class="num">${amt(ev.amount)}</div><div class="t-muted-cell">${fiatSub(ev.amount)}</div></div>
</div>
</summary>
<div class="mv-sub">${list.map(txRow).join('')}</div>
</details>`;
}
function txRow(tx) {
const dirTitle = tx.direction === 'in' ? t('tracking.dirIn') : t('tracking.dirOut');
const arrow = tx.direction === 'in' ? ARROW_DOWN : ARROW_UP;
const links = eventMap.get(tx.id) || [];
const memo = !tx.address ? memoLine(tx.memo, tx.direction) : null;
const leeg = memo ? esc(memo.text) : t('tracking.addLabel');
const mid = tx.labelId
? `<span class="mv-label" data-edit-label data-tx-id="${tx.id}" data-label-id="${tx.labelId}" title="${esc(tx.address || memo?.title || '')}">${esc(tx.label_name)}</span>`
: `<span class="mv-addr" data-edit-label data-tx-id="${tx.id}" data-label-id="" title="${esc(tx.address || memo?.title || '')}">${tx.address ? trunc(tx.address) : leeg}</span>`;
const copyBtn = tx.address ? copyButton(tx.address, t('tracking.copyAddr')) : '';
const feeBlock = (tx.feeSats && tx.feeSats > 0)
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(tx.feeSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.feeSats)}</div>
</div>`
: '';
return `<div class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${tx.id}">${mid}${copyBtn}${matchDot({ matched: links.length > 0, method: links.length ? links[0].method : undefined, kind: 'tx', id: tx.id })}</div>
<div class="mv-amounts">
${feeBlock}
<div class="mv-amt">
<div class="num">${amt(tx.valueSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.valueSats)}</div>
</div>
</div>
</div>`;
}
function openLabelEditor(el, trigger) {
const ids = trigger.dataset.txIds ? JSON.parse(trigger.dataset.txIds) : [Number(trigger.dataset.txId)];
const cur = trigger.dataset.labelId ? Number(trigger.dataset.labelId) : '';
const cell = trigger.closest('[data-cell-tx]');
if (!cell) return;
cell.innerHTML = `<span class="tx-label" data-label-combo>${labelComboMarkup('labelId', cur)}</span>`;
const host = cell.querySelector('[data-label-combo]');
labelComboMount(host, { listLabels, createLabel }, async id => {
await setTxLabels(ids, id);
renderBody(el);
});
const input = host.querySelector('[data-label-input]');
if (input) input.focus();
}
const logRow = (el, id) => el.querySelector(`[data-trk="scanlog"] [data-log="${id}"] .trk-scan-out`);
function logGroup(lijst, account) {
const key = String(account ? account.id : '');
const bestaand = [...lijst.children].find(e => e.dataset.logAcct === key);
if (bestaand) return bestaand.querySelector('.trk-scan-wallets');
const groep = document.createElement('li');
groep.className = 'trk-scan-group';
groep.dataset.logAcct = key;
const kop = document.createElement('div');
kop.className = 't-label t-label--ter trk-scan-acct';
kop.textContent = account ? account.name : '';
const wallets = document.createElement('ul');
wallets.className = 'trk-scan-wallets';
groep.append(kop, wallets);
lijst.append(groep);
return wallets;
}
function addLogRow(el, g) {
const lijst = el.querySelector('[data-trk="scanlog"]');
if (!lijst) return;
const li = document.createElement('li');
li.className = 'trk-scan-row';
li.dataset.log = g.id;
const naam = document.createElement('span');
naam.className = 'trk-scan-name';
naam.textContent = g.name || t('tracking.sourceFallback');
const uit = document.createElement('span');
uit.className = 'trk-scan-out t-muted';
uit.textContent = t('tracking.scanning');
li.append(naam, uit);
logGroup(lijst, g.account).append(li);
}
function setLogRow(el, id, cls, txt) {
const e = logRow(el, id);
if (e) { e.className = `trk-scan-out ${cls}`; e.textContent = txt; }
}
async function scanOne(el, source, logId = source.id) {
return scanSource(source, txt => { const e = logRow(el, logId); if (e) e.textContent = txt; });
}
async function scanAll(el) {
const wallets = await groupedWallets();
if (!wallets.length) return;
const knop = el.querySelector('#scan-all');
const zetStatus = txt => {
const e = el.querySelector('[data-trk="scanstatus"]');
if (e) e.textContent = txt;
};
const results = {};
const kanalen = [];
const voorstellen = new Map();
const lnAccounts = new Set();
let nWallets = 0, nMislukt = 0;
if (knop) knop.disabled = true;
const lijst = el.querySelector('[data-trk="scanlog"]');
if (lijst) lijst.replaceChildren();
const voor = shownTxIds;
newTxIds = new Set();
try {
for (const [i, g] of wallets.entries()) {
zetStatus(t('tracking.scanProgress', { i: i + 1, n: wallets.length }));
addLogRow(el, g);
let count = 0, failed = 0, error = false, geslagen = 0, ln = 0, lnBronnen = 0;
for (const s of g.sources) {
if (isLightningSource(s)) {
lnBronnen++;
lnAccounts.add(s.accountId);
try {
const r = await scanLightningSource(s);
ln += r.count;
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
} catch (err) { console.error('[lightning-scan]', err); error = true; }
continue;
}
if (!isOnchainSource(s)) { geslagen++; continue; }
try {
const r = await scanOne(el, s, g.id);
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
} catch (err) { console.error('[onchain-scan-all]', err); error = true; }
}
const skipped = geslagen && !lnBronnen && !count && !failed;
if (error) nMislukt++; else if (!skipped) nWallets++;
results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
}
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
for (const [accountId, txids] of kanalen) {
await labelChannelTxs(accountId, txids).catch(err => console.error('[lightning-channel-label]', err));
}
const btc = lnAccounts.size ? await btcAsset() : null;
if (btc) for (const accountId of lnAccounts) {
try { voorstellen.set(accountId, await syncTxProposals(accountId, btc.id)); }
catch (err) { console.error('[lightning-proposals]', err); }
}
} finally {
if (knop) knop.disabled = false;
}
const nVoorstellen = [...voorstellen.values()].reduce((s, n) => s + n, 0);
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
for (const id in results) {
const r = results[id];
if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError'));
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
}
scanBaseline = voor;
await renderBody(el);
}
function scanSummary(nWallets, nVoorstellen, nMislukt) {
const deel = [`${fmtInt(nWallets)} ${nWallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')} ${t('tracking.scanUpdated')}`];
if (nVoorstellen) deel.push(`${fmtInt(nVoorstellen)} ${nVoorstellen === 1 ? t('tracking.proposalOne') : t('tracking.proposalMany')}`);
if (nMislukt) deel.push(`${fmtInt(nMislukt)} ${t('tracking.scanFailedN')}`);
return deel.join(' · ');
}
function scanDoneText(r) {
const delen = [];
if (r.count || r.failed || !r.lnBronnen) delen.push(`${r.count} ${t('tracking.addressesScanned')}`);
if (r.failed) delen.push(`${r.failed} ${t('tracking.addressesFailed')}`);
if (r.lnBronnen) delen.push(`${fmtInt(r.ln)} ${t('tracking.lightningMovements')}`);
return delen.join(' · ');
}