mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-11 23:25: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>
93 lines
3.0 KiB
JavaScript
93 lines
3.0 KiB
JavaScript
/* === 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,
|
|
};
|
|
})();
|