Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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(' · ');
|
||||
}
|
||||
Reference in new issue
Block a user