feat: include favorites and claimed nodes in export/import JSON (#1003)

## Summary

Extends the customizer v2 export/import to include favorite nodes and
claimed ("My Mesh") nodes, so users can transfer their full setup
between browsers/devices.

## Changes

### `public/customize-v2.js`
- `readOverrides()` now merges `favorites` (from `meshcore-favorites`)
and `myNodes` (from `meshcore-my-nodes`) into the exported JSON
- `writeOverrides()` extracts `favorites`/`myNodes` arrays and writes
them to their respective localStorage keys, keeping theme overrides
separate
- `validateShape()` validates both new keys as arrays, rejecting
non-array values
- `VALID_SECTIONS` updated to include `favorites` and `myNodes`

### `test-customizer-v2.js`
- 8 new tests covering read/write/validate for both favorites and
myNodes

## TDD
- Red commit: `0405fb7` (failing tests)
- Green commit: `bb9dc34` (implementation)

Fixes #895

---------

Co-authored-by: you <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-05-02 23:04:20 -07:00
committed by GitHub
parent 84ffed96ed
commit 8dfcec2ff3
2 changed files with 102 additions and 6 deletions
+39 -6
View File
@@ -33,7 +33,7 @@
'meshcore-live-heatmap-opacity'
];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit'];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
@@ -313,9 +313,17 @@
function readOverrides() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw == null) return {};
var parsed = JSON.parse(raw);
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
var parsed = (raw != null) ? JSON.parse(raw) : {};
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
// Include favorites and claimed nodes from their own localStorage keys
try {
var favs = JSON.parse(localStorage.getItem('meshcore-favorites') || '[]');
if (Array.isArray(favs) && favs.length) parsed.favorites = favs;
} catch (e) { /* ignore */ }
try {
var myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
if (Array.isArray(myNodes) && myNodes.length) parsed.myNodes = myNodes;
} catch (e) { /* ignore */ }
return parsed;
} catch (e) {
return {};
@@ -386,14 +394,28 @@
function writeOverrides(delta) {
if (delta == null || typeof delta !== 'object') return;
// Extract favorites/myNodes and store in their own localStorage keys
if (Array.isArray(delta.favorites)) {
try { localStorage.setItem('meshcore-favorites', JSON.stringify(delta.favorites)); } catch (e) { /* ignore */ }
}
if (Array.isArray(delta.myNodes)) {
try { localStorage.setItem('meshcore-my-nodes', JSON.stringify(delta.myNodes)); } catch (e) { /* ignore */ }
}
// Build theme-only delta (without favorites/myNodes)
var themeDelta = {};
for (var k in delta) {
if (delta.hasOwnProperty(k) && k !== 'favorites' && k !== 'myNodes') {
themeDelta[k] = delta[k];
}
}
// If empty, remove key entirely
var keys = Object.keys(delta);
var keys = Object.keys(themeDelta);
if (keys.length === 0) {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
_updateSaveStatus('saved');
return;
}
var validated = _validateDelta(delta);
var validated = _validateDelta(themeDelta);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(validated));
_updateSaveStatus('saved');
@@ -758,6 +780,17 @@
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
}
// Validate favorites and myNodes arrays
if (key === 'favorites') {
if (!Array.isArray(obj[key])) {
errors.push('"favorites" must be an array of public key strings');
}
}
if (key === 'myNodes') {
if (!Array.isArray(obj[key])) {
errors.push('"myNodes" must be an array of node objects');
}
}
}
return { valid: errors.length === 0, errors: errors };
}
+63
View File
@@ -512,6 +512,69 @@ test('existing user overrides are NOT pruned by setOverride on other keys', () =
assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored');
});
// ── Fix #895: export/import includes favorites and claimed nodes ──
test('readOverrides includes favorites from localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
ls.setItem('meshcore-favorites', JSON.stringify(['abc123', 'def456']));
const data = api.readOverrides();
assert.deepStrictEqual(data.favorites, ['abc123', 'def456'], 'favorites should be included in export');
});
test('readOverrides includes myNodes from localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
ls.setItem('meshcore-my-nodes', JSON.stringify([{pubkey: 'abc123', name: 'Node1', addedAt: 1000}]));
const data = api.readOverrides();
assert.deepStrictEqual(data.myNodes, [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}], 'myNodes should be included in export');
});
test('writeOverrides restores favorites to localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
api.writeOverrides({ favorites: ['abc123', 'def456'] });
const favs = JSON.parse(ls.getItem('meshcore-favorites') || '[]');
assert.deepStrictEqual(favs, ['abc123', 'def456'], 'favorites should be written to meshcore-favorites');
});
test('writeOverrides restores myNodes to localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
const nodes = [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}];
api.writeOverrides({ myNodes: nodes });
const stored = JSON.parse(ls.getItem('meshcore-my-nodes') || '[]');
assert.deepStrictEqual(stored, nodes, 'myNodes should be written to meshcore-my-nodes');
});
test('validateShape accepts favorites array', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ favorites: ['abc123'] });
assert.ok(result.valid, 'favorites array should be valid');
});
test('validateShape accepts myNodes array', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ myNodes: [{pubkey: 'abc', name: 'N', addedAt: 1}] });
assert.ok(result.valid, 'myNodes array should be valid');
});
test('validateShape rejects non-array favorites', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ favorites: 'not-an-array' });
assert.ok(!result.valid, 'non-array favorites should be invalid');
});
test('validateShape rejects non-array myNodes', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ myNodes: 'not-an-array' });
assert.ok(!result.valid, 'non-array myNodes should be invalid');
});
// ── Summary ──
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);