From aabbd50d27f0546463545344d34729bdf61dbd21 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 2 Sep 2026 14:22:51 +0200 Subject: [PATCH] feat(nodes): export the visible node list as MeshCore companion contacts JSON (#1889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds an **Export JSON** button to the Nodes topbar that downloads the currently visible node list as a MeshCore companion-app config file: ```json { "contacts": [ { "type": 2, "name": "BE-MGU-RP03 | ON7YT", "custom_name": null, "public_key": "7a7a37d4819fb27440ed1439ca7d281fdecceb83b27e61a69745feb004d726a4", "flags": 0, "latitude": "51.07307", "longitude": "5.5796", "last_advert": 1786545431, "last_modified": 1786545431, "out_path_list": null } ] } ``` That is the exact shape the companion app itself writes, so the file imports straight back into a companion app as contacts. The practical use case is per-area: pick an area in the area filter, export, and hand someone the repeaters for that region instead of having them wait for adverts. ## Scope of the export WYSIWYG — the button exports the rows the table is showing, in the table's current order, so area, region, role tab, search, last-heard and status filters all carry over. The button label shows how many contacts the file will contain and is disabled when that count is zero. ## Field mapping | JSON field | Source | Notes | |---|---|---| | `type` | `node.role` | repeater→2, companion→1, room→3, sensor→4; unknown/empty→1 | | `name` | `node.name` | unchanged, emoji included | | `custom_name` | — | always `null` | | `public_key` | `node.public_key` | full 64-hex | | `flags` | — | always `0` | | `latitude` / `longitude` | `node.lat` / `node.lon` | stringified, as the format expects | | `last_advert` | `node.last_seen` | RFC3339 → unix seconds | | `last_modified` | mirrors `last_advert` | no separate source exists | | `out_path_list` | — | always `null`; the companion app discovers routes itself | Nodes are skipped when they have no name, a pubkey shorter than 64 hex chars, or no usable position (missing, non-numeric, or null island). Filename: `corescope_nodes__YYYY-MM-DD-HHMMSS.json`. ## Shape of the change The mapping lives in a self-contained `public/nodes-export.js` (`window.NodesExport.buildContacts/filename/download`); `nodes.js` only gains the button markup, a click handler and a small `updateExportBtn()`. No backend change, no new API call — the export reuses the already-fetched node list, so there is nothing per-node to fetch. ## Tests - `test-nodes-export.js` — field mapping and key order, role→type table, skip rules, order preservation, filename format (added to `test-all.sh`). - `test-nodes-export-wiring.js` — `index.html` loads the module before `nodes.js`; the button lives in the topbar and hands the filtered `nodes` array plus `AreaFilter.getSelected()` to `NodesExport.download()` (added to `test-all.sh`). - `test-nodes-export-e2e.js` — Playwright: downloads the file, validates the JSON shape per contact, asserts the button count matches the file, and that narrowing the search shrinks the export set. ## Browser validation Deployed to staging and checked in Chromium: - Desktop 1400×900 — button renders at the right of the topbar next to the count pills, `Export JSON (1962)`. - Mobile 390×844 — topbar stacks, button visible, no horizontal overflow (`scrollWidth == clientWidth`). - E2E against staging: 1761 contacts exported, `corescope_nodes_all_2026-08-12-164349.json`, shape validated, search narrowing confirmed. - With the `BE-LIM` area filter active: 44 contacts, `corescope_nodes_BE-LIM_2026-08-12-164445.json`, type histogram `{1: 1, 2: 42, 3: 1}`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- public/index.html | 1 + public/nodes-export.js | 92 +++++++++++++++++++++++++++++++++++ public/nodes.js | 17 +++++++ public/style.css | 1 + test-all.sh | 2 + test-nodes-export-e2e.js | 92 +++++++++++++++++++++++++++++++++++ test-nodes-export-wiring.js | 56 ++++++++++++++++++++++ test-nodes-export.js | 96 +++++++++++++++++++++++++++++++++++++ 8 files changed, 357 insertions(+) create mode 100644 public/nodes-export.js create mode 100644 test-nodes-export-e2e.js create mode 100644 test-nodes-export-wiring.js create mode 100644 test-nodes-export.js diff --git a/public/index.html b/public/index.html index d7a15698..1806051c 100644 --- a/public/index.html +++ b/public/index.html @@ -208,6 +208,7 @@ + diff --git a/public/nodes-export.js b/public/nodes-export.js new file mode 100644 index 00000000..d247cd59 --- /dev/null +++ b/public/nodes-export.js @@ -0,0 +1,92 @@ +/* === CoreScope — nodes-export.js (MeshCore companion contacts export) === */ +'use strict'; + +/* + * Exports the visible node list as a MeshCore companion-app config file: + * { "contacts": [ … ] }, the same shape the companion app itself writes, so the + * result can be imported there directly. Kept out of nodes.js so the feature + * stays a self-contained unit. + */ + +(function () { + var ROLE_TYPES = { repeater: 2, companion: 1, room: 3, sensor: 4 }; + var UNKNOWN_TYPE = 1; + + function epochSeconds(ts) { + if (!ts) return 0; + var ms = new Date(ts).getTime(); + return isNaN(ms) ? 0 : Math.floor(ms / 1000); + } + + function coord(v) { + if (v === null || v === undefined || v === '') return null; + var n = Number(v); + return isNaN(n) ? null : n; + } + + // Returns the companion contact for a node, or null when the node cannot be + // represented (no name, truncated pubkey, or no usable position). + function contactFor(n) { + if (!n) return null; + if (typeof n.name !== 'string' || !n.name.trim()) return null; + if (typeof n.public_key !== 'string' || n.public_key.length < 64) return null; + var lat = coord(n.lat); + var lon = coord(n.lon); + if (lat === null || lon === null) return null; + if (lat === 0 && lon === 0) return null; + + var advert = epochSeconds(n.last_seen); + return { + type: ROLE_TYPES[String(n.role || '').toLowerCase()] || UNKNOWN_TYPE, + name: n.name, + custom_name: null, + public_key: n.public_key, + flags: 0, + latitude: String(lat), + longitude: String(lon), + last_advert: advert, + last_modified: advert, + out_path_list: null, + }; + } + + function buildContacts(nodes) { + var contacts = []; + (nodes || []).forEach(function (n) { + var c = contactFor(n); + if (c) contacts.push(c); + }); + return { contacts: contacts }; + } + + function pad2(v) { return v < 10 ? '0' + v : String(v); } + + function filename(areaKey, date) { + var d = date || new Date(); + var stamp = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + + '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds()); + var area = String(areaKey || '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); + return 'corescope_nodes_' + (area || 'all') + '_' + stamp + '.json'; + } + + // Triggers the browser download. Returns the number of exported contacts. + function download(nodes, areaKey) { + var payload = buildContacts(nodes); + var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename(areaKey, new Date()); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 0); + return payload.contacts.length; + } + + window.NodesExport = { + buildContacts: buildContacts, + filename: filename, + download: download, + }; +})(); diff --git a/public/nodes.js b/public/nodes.js index 9edfceba..ed2551b3 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -511,6 +511,8 @@
+
@@ -536,6 +538,12 @@ loadNodes(); }, 250)); + document.getElementById('nodesExportBtn').addEventListener('click', function () { + // WYSIWYG: `nodes` is the list the table is showing, so every active + // filter (area, region, tab, search, last-heard, status) carries over. + window.NodesExport.download(nodes, AreaFilter.getSelected()); + }); + loadNodes(); if (directNode) selectNode(directNode); // Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131) @@ -1274,6 +1282,7 @@ syncClaimedToFavorites(); renderCounts(); + updateExportBtn(); if (refreshOnly) { renderRows(); } else { @@ -1290,6 +1299,14 @@ } } + function updateExportBtn() { + const btn = document.getElementById('nodesExportBtn'); + if (!btn || !window.NodesExport) return; + const n = window.NodesExport.buildContacts(nodes).contacts.length; + btn.disabled = n === 0; + btn.textContent = n ? 'Export JSON (' + n + ')' : 'Export JSON'; + } + function renderCounts() { const el = document.getElementById('nodeCounts'); if (!el) return; diff --git a/public/style.css b/public/style.css index 09f2276f..66761e0a 100644 --- a/public/style.css +++ b/public/style.css @@ -1947,6 +1947,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } font-size: 13px; background: var(--input-bg); color: var(--text); font-family: var(--font); } .nodes-counts { display: flex; gap: 8px; flex-shrink: 0; } +.nodes-export-btn { flex-shrink: 0; white-space: nowrap; } .node-count-pill { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; diff --git a/test-all.sh b/test-all.sh index 898ed20f..bd3b0b69 100755 --- a/test-all.sh +++ b/test-all.sh @@ -69,6 +69,8 @@ node test-issue-1473-reserved-prefixes.js node test-issue-1473-prefix-generator.js node test-issue-1770-mobile-row-clamp.js node test-issue-1849-trace-hashbytes.js +node test-nodes-export.js +node test-nodes-export-wiring.js echo "" echo "═══════════════════════════════════════" diff --git a/test-nodes-export-e2e.js b/test-nodes-export-e2e.js new file mode 100644 index 00000000..e435f0cf --- /dev/null +++ b/test-nodes-export-e2e.js @@ -0,0 +1,92 @@ +// E2E for the Nodes JSON export button (#/nodes → "Export JSON"). +// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL. +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +const REF_KEYS = ['type', 'name', 'custom_name', 'public_key', 'flags', 'latitude', + 'longitude', 'last_advert', 'last_modified', 'out_path_list']; + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage({ acceptDownloads: true }); + + await page.goto(BASE + '/#/nodes'); + await page.waitForSelector('#nodesLeft[data-loaded="true"]', { timeout: 30000 }); + + const btn = await page.$('#nodesExportBtn'); + if (!btn) throw new Error('#nodesExportBtn is missing from the nodes topbar'); + + const label = (await btn.textContent()).trim(); + if (await btn.isDisabled()) { + // No exportable node in this dataset (all lack a name or a position). + if (label !== 'Export JSON') { + throw new Error('disabled button should carry no count, got "' + label + '"'); + } + console.log('nodes-export E2E SKIP (no exportable node in dataset)'); + await browser.close(); + return; + } + + const m = label.match(/^Export JSON \((\d+)\)$/); + if (!m) throw new Error('enabled button must show the contact count, got "' + label + '"'); + const expectedCount = Number(m[1]); + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 15000 }), + btn.click(), + ]); + + const name = download.suggestedFilename(); + if (!/^corescope_nodes_[A-Za-z0-9_-]+_\d{4}-\d{2}-\d{2}-\d{6}\.json$/.test(name)) { + throw new Error('unexpected download filename: ' + name); + } + + const stream = await download.createReadStream(); + let raw = ''; + for await (const chunk of stream) raw += chunk; + const payload = JSON.parse(raw); + + if (!Array.isArray(payload.contacts)) throw new Error('export must have a contacts array'); + if (payload.contacts.length !== expectedCount) { + throw new Error('button said ' + expectedCount + ' contacts, file has ' + payload.contacts.length); + } + if (Object.keys(payload).length !== 1) { + throw new Error('export must contain only the contacts key, got ' + Object.keys(payload).join(',')); + } + + payload.contacts.forEach(function (c, i) { + const keys = Object.keys(c).join(','); + if (keys !== REF_KEYS.join(',')) { + throw new Error('contact ' + i + ' key mismatch: ' + keys); + } + if (typeof c.public_key !== 'string' || c.public_key.length < 64) { + throw new Error('contact ' + i + ' has a truncated public_key'); + } + if (typeof c.latitude !== 'string' || typeof c.longitude !== 'string') { + throw new Error('contact ' + i + ' coordinates must be strings'); + } + if (c.latitude === '0' && c.longitude === '0') { + throw new Error('contact ' + i + ' is at null island and should have been skipped'); + } + if (typeof c.last_advert !== 'number' || typeof c.type !== 'number') { + throw new Error('contact ' + i + ' last_advert/type must be numbers'); + } + }); + + // The export is WYSIWYG: narrowing the table with the search box must narrow + // the export too. + const first = payload.contacts[0].name; + await page.fill('#nodeSearch', first); + await page.waitForFunction(function (want) { + var b = document.getElementById('nodesExportBtn'); + return b && b.textContent.trim() !== want; + }, label, { timeout: 15000 }); + const narrowed = (await page.textContent('#nodesExportBtn')).trim(); + const nm = narrowed.match(/^Export JSON \((\d+)\)$/); + if (!nm || Number(nm[1]) >= expectedCount) { + throw new Error('search should shrink the export set, got "' + narrowed + '" vs ' + expectedCount); + } + + console.log('nodes-export E2E OK (' + expectedCount + ' contacts, ' + name + ')'); + await browser.close(); +})(); diff --git a/test-nodes-export-wiring.js b/test-nodes-export-wiring.js new file mode 100644 index 00000000..1f757a05 --- /dev/null +++ b/test-nodes-export-wiring.js @@ -0,0 +1,56 @@ +/** + * Wiring tests for the Nodes JSON export: index.html loads nodes-export.js, + * and nodes.js renders an Export JSON button in the topbar that hands the + * currently filtered node list + active area key to NodesExport.download(). + * + * Pure source-string assertions (no browser); behavior lives in + * test-nodes-export.js, DOM behavior in test-nodes-export-e2e.js. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0; +let failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8'); +const src = fs.readFileSync(path.join(__dirname, 'public/nodes.js'), 'utf8'); + +console.log('\n=== nodes-export.js is loaded by the SPA shell ==='); +assert(/