mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-10 08:05:46 +00:00
## 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_<area|all>_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) <noreply@anthropic.com>
97 lines
5.7 KiB
JavaScript
97 lines
5.7 KiB
JavaScript
'use strict';
|
|
// Unit test for nodes-export.js — the MeshCore companion "contacts" JSON export
|
|
// of the visible node list. Loads the browser IIFE in a vm sandbox (pattern from
|
|
// test-node-reach-coverage.js) and exercises the pure mapping helpers.
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const code = fs.readFileSync(path.join(__dirname, 'public', 'nodes-export.js'), 'utf8');
|
|
const sandbox = { window: {}, document: {} };
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(code, sandbox);
|
|
|
|
const { buildContacts, filename } = sandbox.window.NodesExport;
|
|
|
|
// ─── Field mapping ───────────────────────────────────────────────────────────
|
|
// The companion app reads a fixed key set; emitting extra/renamed keys makes the
|
|
// import silently drop contacts, so both the keys AND their order are asserted.
|
|
const REF_KEYS = ['type', 'name', 'custom_name', 'public_key', 'flags', 'latitude',
|
|
'longitude', 'last_advert', 'last_modified', 'out_path_list'];
|
|
|
|
const PK = 'efef7943505052b47f1809488ea4b4d3942d4ed72d2b1953b90a9f5e62a65fb5';
|
|
const repeater = {
|
|
public_key: PK, name: 'BE-BRE-ON8AR🔋', role: 'repeater',
|
|
lat: 51.137798, lon: 5.590199, last_seen: '2026-05-14T09:25:43Z',
|
|
};
|
|
|
|
const out = buildContacts([repeater]);
|
|
assert.deepStrictEqual(Object.keys(out), ['contacts'], 'top level is a single contacts array');
|
|
assert.strictEqual(out.contacts.length, 1);
|
|
const c = out.contacts[0];
|
|
assert.deepStrictEqual(Object.keys(c), REF_KEYS, 'contact keys must match the companion format, in order');
|
|
assert.strictEqual(c.type, 2, 'repeater → type 2');
|
|
assert.strictEqual(c.name, 'BE-BRE-ON8AR🔋', 'name passes through unchanged, emoji included');
|
|
assert.strictEqual(c.custom_name, null);
|
|
assert.strictEqual(c.public_key, PK);
|
|
assert.strictEqual(c.flags, 0);
|
|
assert.strictEqual(c.latitude, '51.137798', 'latitude is a string');
|
|
assert.strictEqual(c.longitude, '5.590199', 'longitude is a string');
|
|
assert.strictEqual(c.last_advert, 1778750743, 'last_seen → unix seconds');
|
|
assert.strictEqual(c.last_modified, 1778750743, 'last_modified mirrors last_advert');
|
|
assert.strictEqual(c.out_path_list, null);
|
|
|
|
// ─── Role → type ─────────────────────────────────────────────────────────────
|
|
function typeOf(role) {
|
|
return buildContacts([Object.assign({}, repeater, { role: role })]).contacts[0].type;
|
|
}
|
|
assert.strictEqual(typeOf('repeater'), 2, 'repeater → 2');
|
|
assert.strictEqual(typeOf('companion'), 1, 'companion → 1');
|
|
assert.strictEqual(typeOf('room'), 3, 'room → 3');
|
|
assert.strictEqual(typeOf('sensor'), 4, 'sensor → 4');
|
|
assert.strictEqual(typeOf('Repeater'), 2, 'role match is case-insensitive');
|
|
assert.strictEqual(typeOf('observer'), 1, 'unknown role → 1');
|
|
assert.strictEqual(typeOf(null), 1, 'missing role → 1');
|
|
|
|
// ─── Skip rules ──────────────────────────────────────────────────────────────
|
|
function kept(patch) {
|
|
return buildContacts([Object.assign({}, repeater, patch)]).contacts.length;
|
|
}
|
|
assert.strictEqual(kept({ name: null }), 0, 'no name → skipped');
|
|
assert.strictEqual(kept({ name: ' ' }), 0, 'blank name → skipped');
|
|
assert.strictEqual(kept({ public_key: 'efef7943' }), 0, 'short pubkey → skipped');
|
|
assert.strictEqual(kept({ lat: null }), 0, 'missing lat → skipped');
|
|
assert.strictEqual(kept({ lon: undefined }), 0, 'missing lon → skipped');
|
|
assert.strictEqual(kept({ lat: 0, lon: 0 }), 0, 'null island → skipped');
|
|
assert.strictEqual(kept({ lat: '0', lon: '0' }), 0, 'null island as strings → skipped');
|
|
assert.strictEqual(kept({ lat: 0, lon: 5.59 }), 1, 'lat 0 with a real lon is a valid position');
|
|
assert.strictEqual(kept({ lat: 'n/a' }), 0, 'non-numeric lat → skipped');
|
|
assert.strictEqual(kept({ last_seen: null }), 1, 'a node without last_seen is still exported');
|
|
assert.strictEqual(
|
|
buildContacts([Object.assign({}, repeater, { last_seen: null })]).contacts[0].last_advert, 0,
|
|
'missing last_seen → last_advert 0');
|
|
assert.strictEqual(buildContacts([]).contacts.length, 0, 'empty input → empty contacts');
|
|
assert.strictEqual(buildContacts(null).contacts.length, 0, 'null input → empty contacts');
|
|
|
|
// Order is preserved: the export is WYSIWYG w.r.t. the table's current sort.
|
|
const two = buildContacts([
|
|
Object.assign({}, repeater, { name: 'first' }),
|
|
Object.assign({}, repeater, { name: 'second', public_key: PK.replace(/^efef/, 'aaaa') }),
|
|
]);
|
|
// Joined, not deepStrictEqual: arrays built inside the vm sandbox have a
|
|
// different Array prototype and would fail the realm check.
|
|
assert.strictEqual(two.contacts.map(function (x) { return x.name; }).join(','), 'first,second',
|
|
'input order is preserved');
|
|
|
|
// ─── Filename ────────────────────────────────────────────────────────────────
|
|
const d = new Date(2026, 7, 12, 16, 5, 17); // local time, 2026-08-12 16:05:17
|
|
assert.strictEqual(filename('BE-LIM', d), 'corescope_nodes_BE-LIM_2026-08-12-160517.json');
|
|
assert.strictEqual(filename(null, d), 'corescope_nodes_all_2026-08-12-160517.json',
|
|
'no area selected → "all"');
|
|
assert.strictEqual(filename('', d), 'corescope_nodes_all_2026-08-12-160517.json');
|
|
assert.strictEqual(filename('NL / Zuid', d), 'corescope_nodes_NL_Zuid_2026-08-12-160517.json',
|
|
'unsafe filename chars are collapsed to underscores');
|
|
|
|
console.log('nodes-export mapping, skip rules and filename OK');
|