From 8dfcec2ff31d5960161df79e776e5b0b2f3d94db Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sat, 2 May 2026 23:04:20 -0700 Subject: [PATCH] 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 --- public/customize-v2.js | 45 ++++++++++++++++++++++++++---- test-customizer-v2.js | 63 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/public/customize-v2.js b/public/customize-v2.js index 86295463..101658df 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -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 }; } diff --git a/test-customizer-v2.js b/test-customizer-v2.js index 645a8f2b..03de5446 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -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);