esp32tool 1.6.5 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/nvs-editor.js CHANGED
@@ -12,7 +12,29 @@
12
12
  * - Page state indicator
13
13
  */
14
14
 
15
- import { HexEditor } from './hex-editor.js';
15
+ import { HexEditor } from "./hex-editor.js";
16
+
17
+ // ─────── NVS partition layout constants ───────
18
+ const NVS_SECTOR_SIZE = 4096;
19
+ const MAX_ENTRY_COUNT = 126;
20
+ const NVS_PAGE_STATE = {
21
+ UNINIT: 0xffffffff,
22
+ ACTIVE: 0xfffffffe,
23
+ FULL: 0xfffffffc,
24
+ FREEING: 0xfffffff8,
25
+ CORRUPT: 0xfffffff0,
26
+ };
27
+ const NVS_PAGE_STATE_NAME = {
28
+ [NVS_PAGE_STATE.UNINIT]: "UNINIT",
29
+ [NVS_PAGE_STATE.ACTIVE]: "ACTIVE",
30
+ [NVS_PAGE_STATE.FULL]: "FULL",
31
+ [NVS_PAGE_STATE.FREEING]: "FREEING",
32
+ [NVS_PAGE_STATE.CORRUPT]: "CORRUPT",
33
+ };
34
+
35
+ function pageStateName(stateValue) {
36
+ return NVS_PAGE_STATE_NAME[stateValue >>> 0] || "UNKNOWN";
37
+ }
16
38
 
17
39
  export class NVSEditor {
18
40
  /**
@@ -24,9 +46,9 @@ export class NVSEditor {
24
46
  this.data = null;
25
47
  /** @type {Uint8Array|null} original snapshot for diff */
26
48
  this.originalData = null;
27
- this.baseAddress = 0; // flash offset of the NVS partition
49
+ this.baseAddress = 0; // flash offset of the NVS partition
28
50
  this.partitionSize = 0;
29
- this.partitionName = '';
51
+ this.partitionName = "";
30
52
 
31
53
  /** Parsed pages with items */
32
54
  this.pages = [];
@@ -44,7 +66,7 @@ export class NVSEditor {
44
66
  this._progressBarInner = null;
45
67
 
46
68
  // Filter state
47
- this._filterText = '';
69
+ this._filterText = "";
48
70
 
49
71
  // Sub hex-editor for large entries
50
72
  this._hexEditorInstance = null;
@@ -57,7 +79,7 @@ export class NVSEditor {
57
79
  for (let i = 0; i < 8; i++) {
58
80
  const bit = d & 1;
59
81
  crc ^= bit;
60
- crc = (crc & 1) ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
82
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
61
83
  d >>>= 1;
62
84
  }
63
85
  return crc >>> 0;
@@ -69,51 +91,596 @@ export class NVSEditor {
69
91
  for (let i = 0; i < len; i++) {
70
92
  crc = NVSEditor.crc32Byte(crc, data[offset + i]);
71
93
  }
72
- return (~crc) >>> 0;
94
+ return ~crc >>> 0;
73
95
  }
74
96
 
97
+ /** Entry header CRC: covers bytes [+0..+3] and [+8..+31], stored at [+4..+7]. */
75
98
  static crc32Header(data, offset = 0) {
76
99
  const buf = new Uint8Array(0x20 - 4);
77
100
  buf.set(data.subarray(offset, offset + 4), 0);
78
101
  buf.set(data.subarray(offset + 8, offset + 8 + 0x18), 4);
79
- return NVSEditor.crc32(buf, 0, 0x1C);
102
+ return NVSEditor.crc32(buf, 0, 0x1c);
103
+ }
104
+
105
+ /** Page header CRC: covers bytes [+4..+27] (seqNum..reserved), stored at [+28..+31].
106
+ * State field [+0..+3] excluded — matches nvs_page.cpp Header::calculateCrc32(). */
107
+ static crc32PageHeader(data, offset = 0) {
108
+ return NVSEditor.crc32(data, offset + 4, 24);
80
109
  }
81
110
 
82
- static bytesToHex(bytes, separator = '') {
111
+ static bytesToHex(bytes, separator = "") {
83
112
  return Array.from(bytes)
84
- .map(b => b.toString(16).padStart(2, '0').toUpperCase())
113
+ .map((b) => b.toString(16).padStart(2, "0").toUpperCase())
85
114
  .join(separator);
86
115
  }
87
116
 
117
+ // ─────── Blob helpers (from Berry nvs.be) ───────
118
+
119
+ static isBlob(item) {
120
+ return item.datatype === 0x42 || item.datatype === 0x48;
121
+ }
122
+
123
+ static isBlobData(item) {
124
+ return item.datatype === 0x42;
125
+ }
126
+
127
+ static blobKey(item) {
128
+ return item.key;
129
+ }
130
+
131
+ /** Namespace-qualified blob ID to avoid cross-namespace collisions. */
132
+ static getQualifiedBlobId(item) {
133
+ const ns = item.namespace || `ns_${item.nsIndex}`;
134
+ return `${ns}::${item.key}`;
135
+ }
136
+
137
+ static blobTotalSize(item) {
138
+ if (item.datatype === 0x48 && item.totalSize !== undefined) {
139
+ return item.totalSize;
140
+ }
141
+ if (item.datatype === 0x42 && item.size !== undefined) {
142
+ return item.size;
143
+ }
144
+ return 0;
145
+ }
146
+
147
+ static blobExpectedChunks(item) {
148
+ if (item.datatype === 0x48 && item.chunkCount !== undefined) {
149
+ return item.chunkCount;
150
+ }
151
+ return 0;
152
+ }
153
+
154
+ // ─────── Hex dump function (from Berry nvs.be) ───────
155
+
156
+ static hexDump(data, offset = 0) {
157
+ const width = 16;
158
+ let result = "";
159
+
160
+ for (let i = 0; i < data.length; i += width) {
161
+ const lineOffset = offset + i;
162
+ const hexLine = [];
163
+ const asciiLine = [];
164
+
165
+ for (let j = 0; j < width; j++) {
166
+ if (i + j < data.length) {
167
+ const byte = data[i + j];
168
+ hexLine.push(byte.toString(16).padStart(2, "0").toUpperCase());
169
+ asciiLine.push(
170
+ byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : ".",
171
+ );
172
+ } else {
173
+ hexLine.push(" ");
174
+ asciiLine.push(" ");
175
+ }
176
+ }
177
+
178
+ result += `${lineOffset.toString(16).padStart(6, "0").toUpperCase()} ${hexLine.join(" ")}\t ${asciiLine.join("")}\n`;
179
+ }
180
+
181
+ return result;
182
+ }
183
+
184
+ // ─────── Integrity checking and statistics (from Berry nvs.be) ───────
185
+
186
+ getStatistics() {
187
+ const stats = {
188
+ pages_total: 0,
189
+ pages_active: 0,
190
+ pages_full: 0,
191
+ pages_empty: 0,
192
+ pages_freeing: 0,
193
+ pages_corrupted: 0,
194
+ pages_bad_header_crc: 0,
195
+ entries_written: 0,
196
+ entries_erased: 0,
197
+ entries_empty: 0,
198
+ entries_bad_header_crc: 0,
199
+ entries_bad_data_crc: 0,
200
+ blobs_complete: 0,
201
+ blobs_incomplete: 0,
202
+ };
203
+
204
+ // Build lookup from sector offset → parsed page for entry-level CRC checks.
205
+ const parsedPagesByOffset = new Map();
206
+ for (const p of this.pages) parsedPagesByOffset.set(p.offset, p);
207
+
208
+ for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
209
+ if (secOff + 64 > this.data.length) break;
210
+ stats.pages_total++;
211
+ const stateValue = this._u32(secOff);
212
+ const stateName = pageStateName(stateValue);
213
+
214
+ if (stateName === "ACTIVE") stats.pages_active++;
215
+ else if (stateName === "FULL") stats.pages_full++;
216
+ else if (stateName === "UNINIT") stats.pages_empty++;
217
+ else if (stateName === "FREEING") stats.pages_freeing++;
218
+ else if (stateName === "CORRUPT" || stateName === "UNKNOWN")
219
+ stats.pages_corrupted++;
220
+
221
+ // UNINIT sectors have no header CRC or entries.
222
+ if (stateName === "UNINIT") continue;
223
+
224
+ // Page header CRC (covers bytes +4..+27, stored at +28).
225
+ const pageCrcCalc = NVSEditor.crc32PageHeader(this.data, secOff);
226
+ const pageCrcStored = this._u32(secOff + 28);
227
+ if (pageCrcCalc !== pageCrcStored) stats.pages_bad_header_crc++;
228
+
229
+ // Corrupt/unknown pages have no parseable bitmap or entries.
230
+ if (stateName === "CORRUPT" || stateName === "UNKNOWN") continue;
231
+
232
+ // Iterate ALL slots in the page bitmap to count erased/empty/written.
233
+ // NVS bitmap encoding: 0b00=Erased, 0b10=Written, 0b11=Empty, 0b01=Invalid
234
+ const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
235
+ for (let slotIndex = 0; slotIndex < MAX_ENTRY_COUNT; slotIndex++) {
236
+ const slotState = this._getNVSItemState(stateBitmap, slotIndex);
237
+ if (slotState === 0) stats.entries_erased++;
238
+ else if (slotState === 2) stats.entries_written++;
239
+ else if (slotState === 3) stats.entries_empty++;
240
+ }
241
+
242
+ // Check entry header / data CRC for parsed (WRITTEN) items.
243
+ const parsedPage = parsedPagesByOffset.get(secOff);
244
+ if (parsedPage) {
245
+ for (const item of parsedPage.items) {
246
+ if (!item.headerCrcValid) stats.entries_bad_header_crc++;
247
+ if (item.dataCrcValid !== undefined && !item.dataCrcValid)
248
+ stats.entries_bad_data_crc++;
249
+ }
250
+ }
251
+ }
252
+
253
+ const blobIntegrity = this.checkBlobIntegrity(this.getBlobs());
254
+ stats.blobs_complete = blobIntegrity.complete;
255
+ stats.blobs_incomplete = blobIntegrity.incomplete;
256
+
257
+ return stats;
258
+ }
259
+
260
+ /**
261
+ * Collect detailed integrity issues for diagnostics.
262
+ * Mirrors the kind of info `nvs.be` prints at higher loglevels.
263
+ * @returns {{
264
+ * corruptedPages: Array,
265
+ * pagesBadHeaderCrc: Array,
266
+ * entriesBadHeaderCrc: Array,
267
+ * entriesBadDataCrc: Array,
268
+ * incompleteBlobs: Array
269
+ * }}
270
+ */
271
+ getIntegrityIssues() {
272
+ const issues = {
273
+ corruptedPages: [],
274
+ pagesBadHeaderCrc: [],
275
+ malformedEntries: [], // WRITTEN slots that the parser drops or that overflow
276
+ entriesBadHeaderCrc: [],
277
+ entriesBadDataCrc: [],
278
+ incompleteBlobs: [],
279
+ };
280
+
281
+ // Helper: index parsed pages by sector offset for quick lookup.
282
+ const parsedPagesByOffset = new Map();
283
+ for (const p of this.pages) parsedPagesByOffset.set(p.offset, p);
284
+
285
+ // ── Scan ALL sectors of the partition (independent of this.pages) ──
286
+ for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
287
+ if (secOff + 64 > this.data.length) break;
288
+ const pageIndex = Math.floor(secOff / NVS_SECTOR_SIZE);
289
+ const stateValue = this._u32(secOff);
290
+ const stateName = pageStateName(stateValue);
291
+
292
+ // Corrupted or unknown state pages
293
+ if (stateName === "CORRUPT" || stateName === "UNKNOWN") {
294
+ issues.corruptedPages.push({
295
+ pageIndex,
296
+ offset: secOff,
297
+ state: stateName,
298
+ stateValue: stateValue >>> 0,
299
+ });
300
+ continue; // don't iterate entries on a corrupt/unknown page
301
+ }
302
+
303
+ // UNINIT pages have no meaningful CRC/entries
304
+ if (stateName === "UNINIT") continue;
305
+
306
+ // Check page header CRC
307
+ const pageCrcCalc = NVSEditor.crc32PageHeader(this.data, secOff);
308
+ const pageCrcStored = this._u32(secOff + 28);
309
+ if (pageCrcCalc !== pageCrcStored) {
310
+ issues.pagesBadHeaderCrc.push({
311
+ pageIndex,
312
+ offset: secOff,
313
+ stored: pageCrcStored,
314
+ calculated: pageCrcCalc,
315
+ });
316
+ }
317
+
318
+ // Walk every slot in the bitmap — find malformed WRITTEN entries
319
+ // (these are silently dropped by _parse and would otherwise be invisible).
320
+ const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
321
+ const parsedPage = parsedPagesByOffset.get(secOff);
322
+ const parsedItemsBySlot = new Map();
323
+ if (parsedPage) {
324
+ for (const item of parsedPage.items) {
325
+ const slot = (item.offset - secOff - 64) / 32;
326
+ parsedItemsBySlot.set(slot, item);
327
+ }
328
+ }
329
+
330
+ for (let slot = 0; slot < MAX_ENTRY_COUNT; slot++) {
331
+ const slotState = this._getNVSItemState(stateBitmap, slot);
332
+
333
+ // Invalid bitmap state value (0b01)
334
+ if (slotState === 1) {
335
+ issues.malformedEntries.push({
336
+ pageIndex,
337
+ pageOffset: secOff,
338
+ slot,
339
+ entryOffset: secOff + 64 + slot * 32,
340
+ reason: "Invalid bitmap state (0b01)",
341
+ key: "<unknown>",
342
+ namespace: "<unknown>",
343
+ type: "<unknown>",
344
+ });
345
+ continue;
346
+ }
347
+ if (slotState !== 2) continue; // only WRITTEN slots can be malformed entries
348
+
349
+ const eOff = secOff + 64 + slot * 32;
350
+ if (eOff + 32 > this.data.length) {
351
+ issues.malformedEntries.push({
352
+ pageIndex,
353
+ pageOffset: secOff,
354
+ slot,
355
+ entryOffset: eOff,
356
+ reason: "Entry header truncated by partition end",
357
+ key: "<unknown>",
358
+ namespace: "<unknown>",
359
+ type: "<unknown>",
360
+ });
361
+ continue;
362
+ }
363
+
364
+ const nsIndex = this._u8(eOff);
365
+ const datatype = this._u8(eOff + 1);
366
+ const span = this._u8(eOff + 2);
367
+ const typeName = this._getNVSTypeName(datatype);
368
+
369
+ const reasons = [];
370
+ if (span === 0 || span > 126) reasons.push(`Invalid span (${span})`);
371
+ if (datatype === 0xff || datatype === 0x00)
372
+ reasons.push(`Invalid datatype (0x${datatype.toString(16)})`);
373
+ if (nsIndex === 0xff) reasons.push("Invalid namespace index (0xFF)");
374
+ const key = this._readString(eOff + 8, 16);
375
+ if (nsIndex !== 0 && (!key || key.length === 0))
376
+ reasons.push("Missing/empty key");
377
+ if (span > 0 && span <= 126) {
378
+ const lastEntryEnd = eOff + span * 32;
379
+ if (lastEntryEnd > secOff + NVS_SECTOR_SIZE) {
380
+ reasons.push(
381
+ `Span overflows page (${span} entries from slot ${slot})`,
382
+ );
383
+ }
384
+ }
385
+
386
+ const parsed = parsedItemsBySlot.get(slot);
387
+ if (parsed) {
388
+ // String/blob whose declared size doesn't fit the partition data
389
+ if (
390
+ parsed.value === "<invalid string>" ||
391
+ parsed.value === "<invalid blob>"
392
+ ) {
393
+ reasons.push(
394
+ `Variable-length payload truncated/invalid (${typeName})`,
395
+ );
396
+ }
397
+ }
398
+
399
+ if (reasons.length) {
400
+ issues.malformedEntries.push({
401
+ pageIndex,
402
+ pageOffset: secOff,
403
+ slot,
404
+ entryOffset: eOff,
405
+ reason: reasons.join("; "),
406
+ key: key || "<unknown>",
407
+ namespace: nsIndex === 0 ? "<ns def>" : `ns_${nsIndex}`,
408
+ type: typeName,
409
+ });
410
+ }
411
+
412
+ if (span > 1) slot += span - 1;
413
+ }
414
+
415
+ // Per-item CRC checks for entries the parser DID accept
416
+ if (parsedPage) {
417
+ for (const item of parsedPage.items) {
418
+ const slot = (item.offset - secOff - 64) / 32;
419
+ const baseInfo = {
420
+ pageIndex,
421
+ pageOffset: secOff,
422
+ slot,
423
+ entryOffset: item.offset,
424
+ key: item.key || "<no key>",
425
+ namespace: item.namespace || `ns_${item.nsIndex}`,
426
+ type: item.typeName,
427
+ };
428
+
429
+ if (!item.headerCrcValid) {
430
+ issues.entriesBadHeaderCrc.push({
431
+ ...baseInfo,
432
+ stored: item.crc32 >>> 0,
433
+ calculated: item.headerCrcCalc >>> 0,
434
+ });
435
+ }
436
+ if (item.dataCrcValid === false) {
437
+ issues.entriesBadDataCrc.push({
438
+ ...baseInfo,
439
+ size: item.size,
440
+ stored: item.dataCrcStored >>> 0,
441
+ calculated: item.dataCrcCalc >>> 0,
442
+ });
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ // Blob completeness diagnostics
449
+ const blobs = this.getBlobs();
450
+ for (const [id, blob] of blobs) {
451
+ const presentSet = new Set(blob.chunks.map((c) => c.index));
452
+ const present = presentSet.size;
453
+ const expected = blob.expectedChunks;
454
+ const presentIndices = Array.from(presentSet).sort((a, b) => a - b);
455
+
456
+ let isIncomplete = false;
457
+ let missing = [];
458
+ if (expected > 0) {
459
+ if (present < expected) {
460
+ isIncomplete = true;
461
+ // Determine starting chunk index from blob_index (chunkStart) when available
462
+ const start =
463
+ blob.indexEntry && typeof blob.indexEntry.chunkStart === "number"
464
+ ? blob.indexEntry.chunkStart
465
+ : 0;
466
+ for (let i = 0; i < expected; i++) {
467
+ const idx = start + i;
468
+ if (!presentSet.has(idx)) missing.push(idx);
469
+ }
470
+ }
471
+ } else if (present === 0) {
472
+ isIncomplete = true;
473
+ }
474
+
475
+ if (isIncomplete) {
476
+ issues.incompleteBlobs.push({
477
+ key: `${blob.namespace}::${blob.key}`,
478
+ present,
479
+ expected,
480
+ presentIndices,
481
+ missingIndices: missing,
482
+ totalSize: blob.totalSize,
483
+ indexEntryOffset: blob.indexEntry ? blob.indexEntry.offset : null,
484
+ });
485
+ }
486
+ }
487
+
488
+ return issues;
489
+ }
490
+
491
+ // ─────── Blob integrity checking (from Berry nvs.be) ───────
492
+
493
+ getBlobs() {
494
+ // First pass: collect blob_index and blob_data entries separately per qualified id.
495
+ const indexEntriesById = new Map(); // qualifiedId → blob_index items[]
496
+ const dataEntriesById = new Map(); // qualifiedId → blob_data items[]
497
+
498
+ for (const page of this.pages) {
499
+ for (const item of page.items) {
500
+ if (!NVSEditor.isBlob(item) || !item.key) continue;
501
+ const id = NVSEditor.getQualifiedBlobId(item);
502
+ if (item.datatype === 0x48) {
503
+ if (!indexEntriesById.has(id)) indexEntriesById.set(id, []);
504
+ indexEntriesById.get(id).push(item);
505
+ } else if (item.datatype === 0x42) {
506
+ if (!dataEntriesById.has(id)) dataEntriesById.set(id, []);
507
+ dataEntriesById.get(id).push(item);
508
+ }
509
+ }
510
+ }
511
+
512
+ // Build a chunk array for data items, filtering by the index entry's expected range
513
+ // [chunkStart, chunkStart + expectedChunks) when expectedChunks > 0.
514
+ const buildChunks = (items, expectedChunks, chunkStart) => {
515
+ const chunks = [];
516
+ for (const item of items) {
517
+ if (item.size <= 0) continue;
518
+ const chunkIndex = item.chunkIndex; // raw; must match blob_index chunkStart space
519
+ if (expectedChunks > 0) {
520
+ const rel = chunkIndex - chunkStart;
521
+ if (rel < 0 || rel >= expectedChunks) continue;
522
+ }
523
+ if (item.span > 1) {
524
+ const payloadLength = (item.span - 1) * 32;
525
+ chunks.push({
526
+ offset: item.offset + 32,
527
+ length: Math.min(payloadLength, item.size),
528
+ index: chunkIndex,
529
+ });
530
+ } else {
531
+ // span === 1: inline data stored immediately after the 32-byte header.
532
+ chunks.push({
533
+ offset: item.offset + 32,
534
+ length: item.size,
535
+ index: chunkIndex,
536
+ });
537
+ }
538
+ }
539
+ return chunks;
540
+ };
541
+
542
+ const blobs = new Map();
543
+
544
+ // Blobs with a blob_index entry: pick the best index entry, attach only its chunks.
545
+ for (const [id, indices] of indexEntriesById) {
546
+ // Prefer the first index entry that carries non-zero metadata.
547
+ let best = indices[0];
548
+ for (const idx of indices) {
549
+ if (
550
+ NVSEditor.blobTotalSize(idx) > 0 ||
551
+ NVSEditor.blobExpectedChunks(idx) > 0
552
+ ) {
553
+ best = idx;
554
+ break;
555
+ }
556
+ }
557
+ const expectedChunks = NVSEditor.blobExpectedChunks(best);
558
+ const chunkStart =
559
+ typeof best.chunkStart === "number" ? best.chunkStart : 0;
560
+ blobs.set(id, {
561
+ id,
562
+ key: best.key,
563
+ namespace: best.namespace || `ns_${best.nsIndex}`,
564
+ totalSize: NVSEditor.blobTotalSize(best),
565
+ expectedChunks,
566
+ chunks: buildChunks(
567
+ dataEntriesById.get(id) || [],
568
+ expectedChunks,
569
+ chunkStart,
570
+ ),
571
+ indexEntry: best,
572
+ });
573
+ }
574
+
575
+ // Legacy blobs: blob_data entries with no corresponding blob_index entry.
576
+ for (const [id, items] of dataEntriesById) {
577
+ if (blobs.has(id)) continue;
578
+ const first = items[0];
579
+ blobs.set(id, {
580
+ id,
581
+ key: first.key,
582
+ namespace: first.namespace || `ns_${first.nsIndex}`,
583
+ totalSize: NVSEditor.blobTotalSize(first),
584
+ expectedChunks: 0,
585
+ chunks: buildChunks(items, 0, 0),
586
+ indexEntry: first,
587
+ });
588
+ }
589
+
590
+ return blobs;
591
+ }
592
+
593
+ checkBlobIntegrity(blobs) {
594
+ let complete = 0;
595
+ let incomplete = 0;
596
+
597
+ for (const [key, blob] of blobs) {
598
+ const present = new Set(blob.chunks.map((c) => c.index)).size;
599
+ const expected = blob.expectedChunks;
600
+
601
+ if (expected > 0) {
602
+ if (present >= expected) {
603
+ complete++;
604
+ } else {
605
+ incomplete++;
606
+ }
607
+ } else {
608
+ // Legacy/inline blob - no expected chunk count
609
+ if (present > 0) {
610
+ complete++;
611
+ } else {
612
+ incomplete++;
613
+ }
614
+ }
615
+ }
616
+
617
+ return { complete, incomplete };
618
+ }
619
+
620
+ getBlobData(blob) {
621
+ // Sort chunks by index
622
+ const sortedChunks = [...blob.chunks].sort((a, b) => a.index - b.index);
623
+
624
+ // Assemble blob data
625
+ const totalSize = sortedChunks.reduce(
626
+ (sum, chunk) => sum + chunk.length,
627
+ 0,
628
+ );
629
+ const result = new Uint8Array(totalSize);
630
+ let offset = 0;
631
+
632
+ for (const chunk of sortedChunks) {
633
+ const chunkData = this.data.slice(
634
+ chunk.offset,
635
+ chunk.offset + chunk.length,
636
+ );
637
+ result.set(chunkData, offset);
638
+ offset += chunk.length;
639
+ }
640
+
641
+ return result;
642
+ }
643
+
88
644
  // ─────── Public API ───────
89
645
 
646
+ /** HTML for a progress overlay; reused in initProgressUI() and _buildUI(). */
647
+ static _progressOverlayHtml(extraClass = "", initialText = "Loading...") {
648
+ return `
649
+ <div class="nvseditor-progress-overlay${extraClass ? " " + extraClass : ""}" id="nvsProgress">
650
+ <div class="progress-text" id="nvsProgressText">${initialText}</div>
651
+ <div class="progress-bar-outer">
652
+ <div class="progress-bar-inner" id="nvsProgressBar"></div>
653
+ </div>
654
+ </div>`;
655
+ }
656
+
657
+ /** Cache references to the progress overlay DOM elements. */
658
+ _cacheProgressEls() {
659
+ this._progressOverlay = this.container.querySelector("#nvsProgress");
660
+ this._progressText = this.container.querySelector("#nvsProgressText");
661
+ this._progressBarInner = this.container.querySelector("#nvsProgressBar");
662
+ }
663
+
90
664
  /** Show a progress overlay (before open()) */
91
665
  initProgressUI() {
92
666
  this.container.innerHTML = `
93
667
  <div class="nvseditor-body" style="flex:1;display:flex;align-items:center;justify-content:center;">
94
- <div class="nvseditor-progress-overlay" id="nvsProgress">
95
- <div class="progress-text" id="nvsProgressText">Initiating...</div>
96
- <div class="progress-bar-outer">
97
- <div class="progress-bar-inner" id="nvsProgressBar"></div>
98
- </div>
99
- </div>
668
+ ${NVSEditor._progressOverlayHtml("", "Initiating...")}
100
669
  </div>`;
101
- this._progressOverlay = this.container.querySelector('#nvsProgress');
102
- this._progressText = this.container.querySelector('#nvsProgressText');
103
- this._progressBarInner = this.container.querySelector('#nvsProgressBar');
670
+ this._cacheProgressEls();
104
671
  }
105
672
 
106
673
  showProgress(text, percent) {
107
674
  if (this._progressOverlay) {
108
- this._progressOverlay.classList.remove('hidden');
675
+ this._progressOverlay.classList.remove("hidden");
109
676
  this._progressText.textContent = text;
110
- this._progressBarInner.style.width = percent + '%';
677
+ this._progressBarInner.style.width = percent + "%";
111
678
  }
112
679
  }
113
680
 
114
681
  hideProgress() {
115
682
  if (this._progressOverlay) {
116
- this._progressOverlay.classList.add('hidden');
683
+ this._progressOverlay.classList.add("hidden");
117
684
  }
118
685
  }
119
686
 
@@ -128,33 +695,35 @@ export class NVSEditor {
128
695
  this.originalData = new Uint8Array(data);
129
696
  this.baseAddress = baseAddress;
130
697
  this.partitionSize = data.length;
131
- this.partitionName = name || 'nvs';
698
+ this.partitionName = name || "nvs";
132
699
  this.modified = false;
133
- this._filterText = '';
700
+ this._filterText = "";
134
701
 
135
702
  this.pages = this._parse();
136
703
  this._buildUI();
137
704
 
138
- this.container.classList.remove('hidden');
139
- document.body.classList.add('nvseditor-active');
705
+ this.container.classList.remove("hidden");
706
+ document.body.classList.add("nvseditor-active");
140
707
  }
141
708
 
142
709
  close() {
143
710
  if (this._hexEditorInstance) {
144
711
  this._hexEditorInstance.onClose = null; // prevent re-render on already-closing editor
145
- try { this._hexEditorInstance.close(); } catch (_) {}
712
+ try {
713
+ this._hexEditorInstance.close();
714
+ } catch (_) {}
146
715
  this._hexEditorInstance = null;
147
716
  }
148
- this.container.classList.add('hidden');
149
- document.body.classList.remove('nvseditor-active');
150
- this.container.innerHTML = '';
717
+ this.container.classList.add("hidden");
718
+ document.body.classList.remove("nvseditor-active");
719
+ this.container.innerHTML = "";
151
720
  if (this.onClose) this.onClose();
152
721
  }
153
722
 
154
723
  // ─────── NVS parsing (synchronous, operates on Uint8Array) ───────
155
724
 
156
725
  _readString(offset, maxLen) {
157
- let r = '';
726
+ let r = "";
158
727
  for (let i = 0; i < maxLen; i++) {
159
728
  const b = this.data[offset + i];
160
729
  if (b === 0) break;
@@ -164,22 +733,46 @@ export class NVSEditor {
164
733
  return r;
165
734
  }
166
735
 
167
- _u8(off) { return this.data[off]; }
168
- _u16(off) { return this.data[off] | (this.data[off + 1] << 8); }
169
- _u32(off) { return (this.data[off] | (this.data[off + 1] << 8) | (this.data[off + 2] << 16) | (this.data[off + 3] << 24)) >>> 0; }
170
- _i32(off) { return this._u32(off) | 0; }
736
+ _u8(off) {
737
+ return this.data[off];
738
+ }
739
+ _u16(off) {
740
+ return this.data[off] | (this.data[off + 1] << 8);
741
+ }
742
+ _u32(off) {
743
+ return (
744
+ (this.data[off] |
745
+ (this.data[off + 1] << 8) |
746
+ (this.data[off + 2] << 16) |
747
+ (this.data[off + 3] << 24)) >>>
748
+ 0
749
+ );
750
+ }
751
+ _i32(off) {
752
+ return this._u32(off) | 0;
753
+ }
171
754
  _u64(off) {
172
755
  const lo = this._u32(off);
173
756
  const hi = this._u32(off + 4);
174
757
  return (BigInt(hi) << 32n) | BigInt(lo);
175
758
  }
176
- _i64(off) { return BigInt.asIntN(64, this._u64(off)); }
759
+ _i64(off) {
760
+ return BigInt.asIntN(64, this._u64(off));
761
+ }
177
762
 
178
763
  _getNVSTypeName(dt) {
179
764
  const m = {
180
- 0x01: 'U8', 0x02: 'U16', 0x04: 'U32', 0x08: 'U64',
181
- 0x11: 'I8', 0x12: 'I16', 0x14: 'I32', 0x18: 'I64',
182
- 0x21: 'String', 0x42: 'Blob', 0x48: 'Blob Index'
765
+ 0x01: "U8",
766
+ 0x02: "U16",
767
+ 0x04: "U32",
768
+ 0x08: "U64",
769
+ 0x11: "I8",
770
+ 0x12: "I16",
771
+ 0x14: "I32",
772
+ 0x18: "I64",
773
+ 0x21: "String",
774
+ 0x42: "Blob",
775
+ 0x48: "Blob Index",
183
776
  };
184
777
  return m[dt] || `0x${dt.toString(16)}`;
185
778
  }
@@ -194,58 +787,57 @@ export class NVSEditor {
194
787
  const bmpIdx = Math.floor(index / 4);
195
788
  const bmpBit = (index % 4) * 2;
196
789
  bitmap[bmpIdx] &= ~(3 << bmpBit);
197
- bitmap[bmpIdx] |= (state << bmpBit);
790
+ bitmap[bmpIdx] |= state << bmpBit;
198
791
  }
199
792
 
200
793
  _parse() {
201
- const NVS_SECTOR_SIZE = 4096;
202
- const MAX_ENTRY_COUNT = 126;
203
- const NVS_PAGE_STATE = {
204
- UNINIT: 0xFFFFFFFF, ACTIVE: 0xFFFFFFFE,
205
- FULL: 0xFFFFFFFC, FREEING: 0xFFFFFFF8, CORRUPT: 0xFFFFFFF0
206
- };
207
-
208
794
  const pages = [];
209
795
  const namespaces = new Map();
210
- namespaces.set(0, '');
211
-
212
- // ── Pass 1: collect all namespace definitions ──────────────────────────
213
- for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
214
- if (secOff + 64 > this.data.length) break;
215
- const state = this._u32(secOff);
216
- if (state === NVS_PAGE_STATE.UNINIT || state === NVS_PAGE_STATE.CORRUPT) continue;
217
- const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
218
- for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
219
- if (this._getNVSItemState(stateBitmap, entry) !== 2) continue;
220
- const eOff = secOff + 64 + entry * 32;
221
- if (eOff + 32 > this.data.length) break;
222
- const span = this._u8(eOff + 2);
223
- if (this._u8(eOff) === 0 && this._u8(eOff + 1) !== 0xFF && this._u8(eOff + 1) !== 0x00) {
224
- namespaces.set(this._u8(eOff + 24), this._readString(eOff + 8, 16));
225
- }
226
- if (span > 1) entry += span - 1;
227
- }
228
- }
796
+ namespaces.set(0, "");
229
797
 
798
+ // ── Pass 1: collect all namespace definitions ──────────────────────────
230
799
  for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
231
800
  if (secOff + 64 > this.data.length) break;
232
801
  const state = this._u32(secOff);
802
+ if (state === NVS_PAGE_STATE.UNINIT || state === NVS_PAGE_STATE.CORRUPT)
803
+ continue;
804
+ const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
805
+ for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
806
+ if (this._getNVSItemState(stateBitmap, entry) !== 2) continue;
807
+ const eOff = secOff + 64 + entry * 32;
808
+ if (eOff + 32 > this.data.length) break;
809
+ const span = this._u8(eOff + 2);
810
+ if (
811
+ this._u8(eOff) === 0 &&
812
+ this._u8(eOff + 1) !== 0xff &&
813
+ this._u8(eOff + 1) !== 0x00
814
+ ) {
815
+ namespaces.set(this._u8(eOff + 24), this._readString(eOff + 8, 16));
816
+ }
817
+ if (span > 1) entry += span - 1;
818
+ }
819
+ }
233
820
 
234
- let stateName = 'UNKNOWN';
235
- if (state === NVS_PAGE_STATE.UNINIT) { stateName = 'UNINIT'; }
236
- else if (state === NVS_PAGE_STATE.ACTIVE) { stateName = 'ACTIVE'; }
237
- else if (state === NVS_PAGE_STATE.FULL) { stateName = 'FULL'; }
238
- else if (state === NVS_PAGE_STATE.FREEING) { stateName = 'FREEING'; }
239
- else if (state === NVS_PAGE_STATE.CORRUPT) { stateName = 'CORRUPT'; }
821
+ for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
822
+ if (secOff + 64 > this.data.length) break;
823
+ const state = this._u32(secOff);
824
+ const stateName = pageStateName(state);
240
825
 
241
- if (stateName === 'UNINIT' || stateName === 'CORRUPT') continue;
826
+ if (stateName === "UNINIT" || stateName === "CORRUPT") continue;
242
827
 
243
828
  const seq = this._u32(secOff + 4);
244
829
  const version = this._u8(secOff + 8);
245
830
  const crc32 = this._u32(secOff + 28);
246
831
  const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
247
832
 
248
- const page = { offset: secOff, state: stateName, seq, version, crc32, items: [] };
833
+ const page = {
834
+ offset: secOff,
835
+ state: stateName,
836
+ seq,
837
+ version,
838
+ crc32,
839
+ items: [],
840
+ };
249
841
 
250
842
  for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
251
843
  const itemState = this._getNVSItemState(stateBitmap, entry);
@@ -260,8 +852,8 @@ export class NVSEditor {
260
852
  const chunkIndex = this._u8(eOff + 3);
261
853
 
262
854
  if (span === 0 || span > 126) continue;
263
- if (datatype === 0xFF || datatype === 0x00) continue;
264
- if (nsIndex === 0xFF) continue;
855
+ if (datatype === 0xff || datatype === 0x00) continue;
856
+ if (nsIndex === 0xff) continue;
265
857
 
266
858
  const crc = this._u32(eOff + 4);
267
859
  const key = this._readString(eOff + 8, 16);
@@ -271,16 +863,19 @@ export class NVSEditor {
271
863
  const headerCrcCalc = NVSEditor.crc32Header(this.data, eOff);
272
864
 
273
865
  const item = {
274
- nsIndex, datatype, span, chunkIndex,
866
+ nsIndex,
867
+ datatype,
868
+ span,
869
+ chunkIndex,
275
870
  crc32: crc >>> 0,
276
871
  headerCrcCalc: headerCrcCalc >>> 0,
277
- headerCrcValid: (crc >>> 0) === (headerCrcCalc >>> 0),
872
+ headerCrcValid: crc >>> 0 === headerCrcCalc >>> 0,
278
873
  key,
279
874
  value: null,
280
875
  typeName: this._getNVSTypeName(datatype),
281
876
  offset: eOff,
282
877
  entrySize: 32,
283
- pageOffset: secOff
878
+ pageOffset: secOff,
284
879
  };
285
880
 
286
881
  // Namespace definition
@@ -291,27 +886,61 @@ export class NVSEditor {
291
886
  namespaces.set(namespaceIndex, key);
292
887
  } else {
293
888
  switch (datatype) {
294
- case 0x01: item.value = this._u8(eOff + 24); break;
295
- case 0x02: item.value = this._u16(eOff + 24); break;
296
- case 0x04: item.value = this._u32(eOff + 24); break;
297
- case 0x08: item.value = this._u64(eOff + 24).toString(); break;
298
- case 0x11: item.value = (this._u8(eOff + 24) > 127 ? this._u8(eOff + 24) - 256 : this._u8(eOff + 24)); break;
299
- case 0x12: { const v = this._u16(eOff + 24); item.value = v > 32767 ? v - 65536 : v; break; }
300
- case 0x14: item.value = this._i32(eOff + 24); break;
301
- case 0x18: item.value = this._i64(eOff + 24).toString(); break;
302
- case 0x21: { // String
889
+ case 0x01:
890
+ item.value = this._u8(eOff + 24);
891
+ break;
892
+ case 0x02:
893
+ item.value = this._u16(eOff + 24);
894
+ break;
895
+ case 0x04:
896
+ item.value = this._u32(eOff + 24);
897
+ break;
898
+ case 0x08:
899
+ item.value = this._u64(eOff + 24).toString();
900
+ break;
901
+ case 0x11:
902
+ item.value =
903
+ this._u8(eOff + 24) > 127
904
+ ? this._u8(eOff + 24) - 256
905
+ : this._u8(eOff + 24);
906
+ break;
907
+ case 0x12: {
908
+ const v = this._u16(eOff + 24);
909
+ item.value = v > 32767 ? v - 65536 : v;
910
+ break;
911
+ }
912
+ case 0x14:
913
+ item.value = this._i32(eOff + 24);
914
+ break;
915
+ case 0x18:
916
+ item.value = this._i64(eOff + 24).toString();
917
+ break;
918
+ case 0x21: {
919
+ // String
303
920
  const strSize = this._u16(eOff + 24);
304
921
  const strCrc = this._u32(eOff + 28);
305
- if (strSize > 0 && strSize < 4096 && eOff + 32 + strSize <= this.data.length) {
922
+ if (
923
+ strSize > 0 &&
924
+ strSize < 4096 &&
925
+ eOff + 32 + strSize <= this.data.length
926
+ ) {
306
927
  const strData = this.data.slice(eOff + 32, eOff + 32 + strSize);
307
- const allErased = strData.every(b => b === 0xFF);
928
+ const allErased = strData.every((b) => b === 0xff);
308
929
  // Find first NUL byte and decode as UTF-8
309
930
  let nullIndex = strData.length;
310
931
  for (let i = 0; i < strData.length; i++) {
311
- if (strData[i] === 0) { nullIndex = i; break; }
932
+ if (strData[i] === 0) {
933
+ nullIndex = i;
934
+ break;
935
+ }
312
936
  }
313
- const sv = nullIndex > 0 ? new TextDecoder('utf-8').decode(strData.subarray(0, nullIndex)) : '';
314
- item.value = allErased ? '<erased>' : sv;
937
+ const sv =
938
+ nullIndex > 0
939
+ ? new TextDecoder("utf-8").decode(
940
+ strData.subarray(0, nullIndex),
941
+ )
942
+ : "";
943
+ item.value = allErased ? "<erased>" : sv;
315
944
  item.rawValue = strData;
316
945
  item.dataCrcStored = strCrc >>> 0;
317
946
  item.dataCrcCalc = NVSEditor.crc32(strData, 0, strSize) >>> 0;
@@ -319,18 +948,28 @@ export class NVSEditor {
319
948
  item.size = strSize;
320
949
  item.entrySize = 32 + strSize;
321
950
  } else {
322
- item.value = '<invalid string>';
951
+ item.value = "<invalid string>";
323
952
  item.size = 0;
324
953
  }
325
954
  break;
326
955
  }
327
- case 0x42: { // Blob
956
+ case 0x42: {
957
+ // Blob
328
958
  const blobSize = this._u16(eOff + 24);
329
959
  const blobCrc = this._u32(eOff + 28);
330
- if (blobSize > 0 && blobSize < 4096 && eOff + 32 + blobSize <= this.data.length) {
331
- const blobData = this.data.slice(eOff + 32, eOff + 32 + blobSize);
332
- const allErased = blobData.every(b => b === 0xFF);
333
- item.value = allErased ? '<erased>' : NVSEditor.bytesToHex(blobData, ' ');
960
+ if (
961
+ blobSize > 0 &&
962
+ blobSize < 4096 &&
963
+ eOff + 32 + blobSize <= this.data.length
964
+ ) {
965
+ const blobData = this.data.slice(
966
+ eOff + 32,
967
+ eOff + 32 + blobSize,
968
+ );
969
+ const allErased = blobData.every((b) => b === 0xff);
970
+ item.value = allErased
971
+ ? "<erased>"
972
+ : NVSEditor.bytesToHex(blobData, " ");
334
973
  item.rawValue = blobData;
335
974
  item.dataCrcStored = blobCrc >>> 0;
336
975
  item.dataCrcCalc = NVSEditor.crc32(blobData, 0, blobSize) >>> 0;
@@ -338,12 +977,13 @@ export class NVSEditor {
338
977
  item.size = blobSize;
339
978
  item.entrySize = 32 + blobSize;
340
979
  } else {
341
- item.value = '<invalid blob>';
980
+ item.value = "<invalid blob>";
342
981
  item.size = 0;
343
982
  }
344
983
  break;
345
984
  }
346
- case 0x48: { // Blob Index
985
+ case 0x48: {
986
+ // Blob Index
347
987
  item.totalSize = this._u32(eOff + 24);
348
988
  item.chunkCount = this._u8(eOff + 28);
349
989
  item.chunkStart = this._u8(eOff + 29);
@@ -359,11 +999,11 @@ export class NVSEditor {
359
999
 
360
1000
  pages.push(page);
361
1001
  }
362
- // Resolve all item namespaces after both passes
363
- for (const page of pages)
364
- for (const it of page.items)
365
- if (it.nsIndex && it.nsIndex !== 0)
366
- it.namespace = namespaces.get(it.nsIndex) || `ns_${it.nsIndex}`;
1002
+ // Resolve all item namespaces after both passes
1003
+ for (const page of pages)
1004
+ for (const it of page.items)
1005
+ if (it.nsIndex && it.nsIndex !== 0)
1006
+ it.namespace = namespaces.get(it.nsIndex) || `ns_${it.nsIndex}`;
367
1007
 
368
1008
  return pages;
369
1009
  }
@@ -379,7 +1019,7 @@ export class NVSEditor {
379
1019
 
380
1020
  for (let s = 0; s < item.span; s++) {
381
1021
  const off = item.offset + s * 32;
382
- this.data.fill(0xFF, off, off + 32);
1022
+ this.data.fill(0xff, off, off + 32);
383
1023
  this._setNVSItemState(stateBitmap, entryIdx + s, 0); // ERASED = 0
384
1024
  }
385
1025
  // Write back bitmap
@@ -390,11 +1030,12 @@ export class NVSEditor {
390
1030
  // ─────── UI ───────
391
1031
 
392
1032
  _buildUI() {
393
- const sizeStr = this.partitionSize >= 1024 * 1024
394
- ? (this.partitionSize / (1024 * 1024)).toFixed(1) + ' MB'
395
- : this.partitionSize >= 1024
396
- ? (this.partitionSize / 1024).toFixed(1) + ' KB'
397
- : this.partitionSize + ' B';
1033
+ const sizeStr =
1034
+ this.partitionSize >= 1024 * 1024
1035
+ ? (this.partitionSize / (1024 * 1024)).toFixed(1) + " MB"
1036
+ : this.partitionSize >= 1024
1037
+ ? (this.partitionSize / 1024).toFixed(1) + " KB"
1038
+ : this.partitionSize + " B";
398
1039
 
399
1040
  const totalItems = this.pages.reduce((s, p) => s + p.items.length, 0);
400
1041
 
@@ -411,17 +1052,14 @@ export class NVSEditor {
411
1052
  <div class="nvseditor-filter">
412
1053
  <input id="nvsFilter" type="text" placeholder="Filter by namespace or key..." />
413
1054
  </div>
1055
+ <button id="nvsStats" title="Show statistics and integrity report">📊 Stats</button>
1056
+ <button id="nvsBlobs" title="Show blob information">📦 Blobs</button>
414
1057
  <button id="nvsRefresh" title="Re-parse data">Refresh</button>
415
1058
  <button id="nvsWrite" class="primary" disabled>Write to Flash</button>
416
1059
  <button id="nvsClose">Close</button>
417
1060
  </div>
418
1061
  <div class="nvseditor-body">
419
- <div class="nvseditor-progress-overlay hidden" id="nvsProgress">
420
- <div class="progress-text" id="nvsProgressText">Loading...</div>
421
- <div class="progress-bar-outer">
422
- <div class="progress-bar-inner" id="nvsProgressBar"></div>
423
- </div>
424
- </div>
1062
+ ${NVSEditor._progressOverlayHtml("hidden", "Loading...")}
425
1063
  <div class="nvseditor-content" id="nvsContent"></div>
426
1064
  </div>
427
1065
  <div class="nvseditor-statusbar">
@@ -429,35 +1067,35 @@ export class NVSEditor {
429
1067
  </div>
430
1068
  <div id="nvsHexEditorContainer" class="hexeditor-container hidden"></div>`;
431
1069
 
432
- this._hexEditorContainer = this.container.querySelector('#nvsHexEditorContainer');
1070
+ this._hexEditorContainer = this.container.querySelector(
1071
+ "#nvsHexEditorContainer",
1072
+ );
433
1073
 
434
- this._progressOverlay = this.container.querySelector('#nvsProgress');
435
- this._progressText = this.container.querySelector('#nvsProgressText');
436
- this._progressBarInner = this.container.querySelector('#nvsProgressBar');
1074
+ this._cacheProgressEls();
437
1075
 
438
1076
  // Close
439
- this.container.querySelector('#nvsClose').addEventListener('click', () => {
1077
+ this.container.querySelector("#nvsClose").addEventListener("click", () => {
440
1078
  if (this.modified) {
441
- if (!confirm('You have unsaved modifications. Close anyway?')) return;
1079
+ if (!confirm("You have unsaved modifications. Close anyway?")) return;
442
1080
  }
443
1081
  this.close();
444
1082
  });
445
1083
 
446
1084
  // Write
447
- const butWrite = this.container.querySelector('#nvsWrite');
448
- butWrite.addEventListener('click', async () => {
1085
+ const butWrite = this.container.querySelector("#nvsWrite");
1086
+ butWrite.addEventListener("click", async () => {
449
1087
  if (!this.onWriteFlash) return;
450
1088
  butWrite.disabled = true;
451
1089
  try {
452
- this.showProgress('Writing NVS to flash...', 0);
1090
+ this.showProgress("Writing NVS to flash...", 0);
453
1091
  await this.onWriteFlash(this.data);
454
1092
  this.originalData = new Uint8Array(this.data);
455
1093
  this.modified = false;
456
1094
  butWrite.disabled = true;
457
- this.showProgress('Write complete!', 100);
1095
+ this.showProgress("Write complete!", 100);
458
1096
  setTimeout(() => this.hideProgress(), 1000);
459
1097
  } catch (e) {
460
- alert('Write failed: ' + e);
1098
+ alert("Write failed: " + e);
461
1099
  this.hideProgress();
462
1100
  } finally {
463
1101
  butWrite.disabled = this.modified === false;
@@ -465,33 +1103,56 @@ export class NVSEditor {
465
1103
  });
466
1104
 
467
1105
  // Refresh
468
- this.container.querySelector('#nvsRefresh').addEventListener('click', () => {
469
- this.pages = this._parse();
470
- this._renderContent();
471
- });
1106
+ this.container
1107
+ .querySelector("#nvsRefresh")
1108
+ .addEventListener("click", () => {
1109
+ this.pages = this._parse();
1110
+ this._renderContent();
1111
+ });
472
1112
 
473
1113
  // Filter
474
- this.container.querySelector('#nvsFilter').addEventListener('input', (e) => {
475
- this._filterText = e.target.value.toLowerCase();
476
- this._renderContent();
1114
+ this.container
1115
+ .querySelector("#nvsFilter")
1116
+ .addEventListener("input", (e) => {
1117
+ this._filterText = e.target.value.toLowerCase();
1118
+ this._renderContent();
1119
+ });
1120
+
1121
+ // Stats button
1122
+ this.container.querySelector("#nvsStats").addEventListener("click", () => {
1123
+ this._showStats();
1124
+ });
1125
+
1126
+ // Blobs button
1127
+ this.container.querySelector("#nvsBlobs").addEventListener("click", () => {
1128
+ this._showBlobs();
477
1129
  });
478
1130
 
479
1131
  this._renderContent();
480
1132
  }
481
1133
 
482
1134
  _esc(s) {
483
- const d = document.createElement('span');
1135
+ const d = document.createElement("span");
484
1136
  d.textContent = s;
485
1137
  return d.innerHTML;
486
1138
  }
487
1139
 
1140
+ _matchesFilter(ns, item, filter) {
1141
+ if (!filter) return true;
1142
+ return (
1143
+ ns.toLowerCase().includes(filter) ||
1144
+ item.key.toLowerCase().includes(filter) ||
1145
+ String(item.value).toLowerCase().includes(filter)
1146
+ );
1147
+ }
1148
+
488
1149
  _renderContent() {
489
- const content = this.container.querySelector('#nvsContent');
1150
+ const content = this.container.querySelector("#nvsContent");
490
1151
  if (!content) return;
491
1152
 
492
1153
  const filter = this._filterText;
493
1154
 
494
- let html = '';
1155
+ let html = "";
495
1156
 
496
1157
  for (const page of this.pages) {
497
1158
  // Group items by namespace
@@ -512,42 +1173,47 @@ export class NVSEditor {
512
1173
  let hasVisibleItems = false;
513
1174
  if (filter) {
514
1175
  for (const [ns, items] of nsGroups) {
515
- const filtered = items.filter(it =>
516
- ns.toLowerCase().includes(filter) ||
517
- it.key.toLowerCase().includes(filter) ||
518
- String(it.value).toLowerCase().includes(filter)
519
- );
520
- if (filtered.length > 0) hasVisibleItems = true;
1176
+ if (items.some((it) => this._matchesFilter(ns, it, filter))) {
1177
+ hasVisibleItems = true;
1178
+ break;
1179
+ }
521
1180
  }
522
1181
  // Also check namespace defs
523
- for (const nd of nsDefs) {
524
- if (nd.key.toLowerCase().includes(filter)) hasVisibleItems = true;
1182
+ if (!hasVisibleItems) {
1183
+ for (const nd of nsDefs) {
1184
+ if (nd.key.toLowerCase().includes(filter)) {
1185
+ hasVisibleItems = true;
1186
+ break;
1187
+ }
1188
+ }
525
1189
  }
526
1190
  if (!hasVisibleItems) continue;
527
1191
  } else {
528
1192
  hasVisibleItems = true;
529
1193
  }
530
1194
 
531
- const stateClass = page.state === 'ACTIVE' ? 'state-active' :
532
- page.state === 'FULL' ? 'state-full' :
533
- page.state === 'FREEING' ? 'state-freeing' : 'state-other';
1195
+ const stateClass =
1196
+ page.state === "ACTIVE"
1197
+ ? "state-active"
1198
+ : page.state === "FULL"
1199
+ ? "state-full"
1200
+ : page.state === "FREEING"
1201
+ ? "state-freeing"
1202
+ : "state-other";
534
1203
 
535
- html += `<div class="nvs-page">
1204
+ html += `<div class="nvs-page" data-page-offset="${page.offset}">
536
1205
  <div class="nvs-page-header ${stateClass}">
537
1206
  <span class="nvs-page-state">${page.state}</span>
538
1207
  <span>Page @ 0x${page.offset.toString(16).toUpperCase()}</span>
539
1208
  <span>Seq: ${page.seq}</span>
540
- <span>Version: ${page.version === 0xFF ? 'v1' : page.version === 0xFE ? 'v2' : page.version}</span>
1209
+ <span>Version: ${page.version === 0xff ? "v1" : page.version === 0xfe ? "v2" : page.version}</span>
541
1210
  <span>${page.items.length} entries</span>
542
1211
  </div>`;
543
1212
 
544
1213
  // Render namespace groups
545
1214
  for (const [ns, items] of nsGroups) {
546
1215
  const filteredItems = filter
547
- ? items.filter(it =>
548
- ns.toLowerCase().includes(filter) ||
549
- it.key.toLowerCase().includes(filter) ||
550
- String(it.value).toLowerCase().includes(filter))
1216
+ ? items.filter((it) => this._matchesFilter(ns, it, filter))
551
1217
  : items;
552
1218
  if (filteredItems.length === 0) continue;
553
1219
 
@@ -565,23 +1231,25 @@ export class NVSEditor {
565
1231
 
566
1232
  for (const item of filteredItems) {
567
1233
  const crcOk = item.headerCrcValid !== false;
568
- const dataCrcOk = item.dataCrcValid !== undefined ? item.dataCrcValid : true;
569
- const crcClass = (crcOk && dataCrcOk) ? 'crc-ok' : 'crc-bad';
570
- const crcText = (crcOk && dataCrcOk) ? '✓' : '✗';
1234
+ const dataCrcOk =
1235
+ item.dataCrcValid !== undefined ? item.dataCrcValid : true;
1236
+ const crcClass = crcOk && dataCrcOk ? "crc-ok" : "crc-bad";
1237
+ const crcText = crcOk && dataCrcOk ? "✓" : "✗";
571
1238
 
572
- let displayValue = String(item.value ?? '');
573
- if (displayValue.length > 120) displayValue = displayValue.substring(0, 120) + '…';
1239
+ let displayValue = String(item.value ?? "");
1240
+ if (displayValue.length > 120)
1241
+ displayValue = displayValue.substring(0, 120) + "…";
574
1242
 
575
1243
  const editable = true;
576
1244
 
577
- html += `<tr>
1245
+ html += `<tr data-offset="${item.offset}">
578
1246
  <td class="nvs-key" title="${this._esc(item.key)}">${this._esc(item.key)}</td>
579
1247
  <td class="nvs-type">${this._esc(item.typeName)}</td>
580
- <td class="nvs-value" title="${this._esc(String(item.value ?? ''))}">${this._esc(displayValue)}</td>
1248
+ <td class="nvs-value" title="${this._esc(String(item.value ?? ""))}">${this._esc(displayValue)}</td>
581
1249
  <td class="nvs-crc ${crcClass}">${crcText}</td>
582
1250
  <td class="nvs-offset">0x${(this.baseAddress + item.offset).toString(16).toUpperCase()}</td>
583
1251
  <td class="nvs-actions">
584
- ${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` : ''}
1252
+ ${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` : ""}
585
1253
  <button class="nvs-btn-delete" data-offset="${item.offset}" title="Delete entry">✕</button>
586
1254
  </td>
587
1255
  </tr>`;
@@ -593,23 +1261,26 @@ export class NVSEditor {
593
1261
  html += `</div>`;
594
1262
  }
595
1263
 
596
- if (html === '') {
597
- html = '<div class="nvs-empty">No NVS entries found' + (filter ? ' matching filter' : '') + '</div>';
1264
+ if (html === "") {
1265
+ html =
1266
+ '<div class="nvs-empty">No NVS entries found' +
1267
+ (filter ? " matching filter" : "") +
1268
+ "</div>";
598
1269
  }
599
1270
 
600
1271
  content.innerHTML = html;
601
1272
 
602
1273
  // Bind edit buttons
603
- content.querySelectorAll('.nvs-btn-edit').forEach(btn => {
604
- btn.addEventListener('click', () => {
1274
+ content.querySelectorAll(".nvs-btn-edit").forEach((btn) => {
1275
+ btn.addEventListener("click", () => {
605
1276
  const off = parseInt(btn.dataset.offset, 10);
606
1277
  this._editItem(off);
607
1278
  });
608
1279
  });
609
1280
 
610
1281
  // Bind delete buttons
611
- content.querySelectorAll('.nvs-btn-delete').forEach(btn => {
612
- btn.addEventListener('click', () => {
1282
+ content.querySelectorAll(".nvs-btn-delete").forEach((btn) => {
1283
+ btn.addEventListener("click", () => {
613
1284
  const off = parseInt(btn.dataset.offset, 10);
614
1285
  this._deleteItemUI(off);
615
1286
  });
@@ -618,6 +1289,32 @@ export class NVSEditor {
618
1289
  this._updateWriteButton();
619
1290
  }
620
1291
 
1292
+ /** Scroll an entry row into view and highlight it briefly. */
1293
+ _scrollToEntry(offset) {
1294
+ const content = this.container.querySelector("#nvsContent");
1295
+ if (!content) return false;
1296
+ const row = content.querySelector(`tr[data-offset="${offset}"]`);
1297
+ if (!row) return false;
1298
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
1299
+ row.classList.add("nvs-row-highlight");
1300
+ setTimeout(() => row.classList.remove("nvs-row-highlight"), 2200);
1301
+ return true;
1302
+ }
1303
+
1304
+ /** Scroll a page section into view and highlight it briefly. */
1305
+ _scrollToPage(pageOffset) {
1306
+ const content = this.container.querySelector("#nvsContent");
1307
+ if (!content) return false;
1308
+ const pageEl = content.querySelector(
1309
+ `.nvs-page[data-page-offset="${pageOffset}"]`,
1310
+ );
1311
+ if (!pageEl) return false;
1312
+ pageEl.scrollIntoView({ behavior: "smooth", block: "start" });
1313
+ pageEl.classList.add("nvs-page-highlight");
1314
+ setTimeout(() => pageEl.classList.remove("nvs-page-highlight"), 2200);
1315
+ return true;
1316
+ }
1317
+
621
1318
  _findItem(offset) {
622
1319
  for (const page of this.pages) {
623
1320
  for (const item of page.items) {
@@ -653,7 +1350,10 @@ export class NVSEditor {
653
1350
  dataOffset = off + 32;
654
1351
  dataSize = item.size || (item.rawValue ? item.rawValue.length : 0);
655
1352
  maxSize = (item.span - 1) * 32;
656
- if (dataSize <= 0) { alert('No data to edit'); return; }
1353
+ if (dataSize <= 0) {
1354
+ alert("No data to edit");
1355
+ return;
1356
+ }
657
1357
  }
658
1358
 
659
1359
  const entryData = this.data.slice(dataOffset, dataOffset + dataSize);
@@ -662,18 +1362,23 @@ export class NVSEditor {
662
1362
  this._hexEditorInstance = new HexEditor(this._hexEditorContainer);
663
1363
  }
664
1364
 
665
- this._hexEditorContainer.classList.remove('hidden');
1365
+ this._hexEditorContainer.classList.remove("hidden");
666
1366
  this._hexEditorInstance.open(entryData, 0);
667
1367
 
668
1368
  // Relabel button and show entry info
669
- const writeBtn = this._hexEditorContainer.querySelector('#hexedWrite');
670
- if (writeBtn) writeBtn.textContent = 'Apply Changes';
1369
+ const writeBtn = this._hexEditorContainer.querySelector("#hexedWrite");
1370
+ if (writeBtn) writeBtn.textContent = "Apply Changes";
671
1371
 
672
- this._hexEditorInstance.onWriteFlash = async (editedData, modifiedOffsets) => {
1372
+ this._hexEditorInstance.onWriteFlash = async (
1373
+ editedData,
1374
+ modifiedOffsets,
1375
+ ) => {
673
1376
  if (modifiedOffsets.size === 0) return;
674
1377
 
675
1378
  if (editedData.length > maxSize) {
676
- alert('Edited data exceeds available slot size (' + maxSize + ' bytes)');
1379
+ alert(
1380
+ "Edited data exceeds available slot size (" + maxSize + " bytes)",
1381
+ );
677
1382
  return;
678
1383
  }
679
1384
 
@@ -682,11 +1387,11 @@ export class NVSEditor {
682
1387
  this.data.set(editedData.slice(0, 8), dataOffset);
683
1388
  } else {
684
1389
  // Clear payload area, then write
685
- this.data.fill(0xFF, dataOffset, dataOffset + maxSize);
1390
+ this.data.fill(0xff, dataOffset, dataOffset + maxSize);
686
1391
  this.data.set(editedData, dataOffset);
687
1392
  // Update size field
688
- this.data[off + 24] = editedData.length & 0xFF;
689
- this.data[off + 25] = (editedData.length >> 8) & 0xFF;
1393
+ this.data[off + 24] = editedData.length & 0xff;
1394
+ this.data[off + 25] = (editedData.length >> 8) & 0xff;
690
1395
  // Update data CRC
691
1396
  const crc = NVSEditor.crc32(editedData);
692
1397
  const dv = new DataView(this.data.buffer, off + 28, 4);
@@ -700,13 +1405,13 @@ export class NVSEditor {
700
1405
 
701
1406
  this.modified = true;
702
1407
 
703
- this._hexEditorInstance.showProgress('Applied to NVS!', 100);
704
- await new Promise(r => setTimeout(r, 500));
1408
+ this._hexEditorInstance.showProgress("Applied to NVS!", 100);
1409
+ await new Promise((r) => setTimeout(r, 500));
705
1410
  this._hexEditorInstance.hideProgress();
706
1411
  };
707
1412
 
708
1413
  this._hexEditorInstance.onClose = () => {
709
- this._hexEditorContainer.classList.add('hidden');
1414
+ this._hexEditorContainer.classList.add("hidden");
710
1415
  this._hexEditorInstance = null;
711
1416
  this.pages = this._parse();
712
1417
  this._renderContent();
@@ -726,7 +1431,309 @@ export class NVSEditor {
726
1431
  }
727
1432
 
728
1433
  _updateWriteButton() {
729
- const btn = this.container.querySelector('#nvsWrite');
1434
+ const btn = this.container.querySelector("#nvsWrite");
730
1435
  if (btn) btn.disabled = !this.modified;
731
1436
  }
1437
+
1438
+ // ─────── UI dialogs for new features ───────
1439
+
1440
+ /** Create a modal dialog from `html`, append to <body>, wire up close handlers. */
1441
+ _createDialog(html) {
1442
+ const dialogContainer = document.createElement("div");
1443
+ dialogContainer.innerHTML = html;
1444
+ document.body.appendChild(dialogContainer);
1445
+
1446
+ const overlay = dialogContainer.querySelector(".nvs-dialog-overlay");
1447
+ dialogContainer
1448
+ .querySelector(".nvs-dialog-close")
1449
+ .addEventListener("click", () => {
1450
+ dialogContainer.remove();
1451
+ });
1452
+ overlay.addEventListener("click", (e) => {
1453
+ if (e.target === overlay) dialogContainer.remove();
1454
+ });
1455
+ return dialogContainer;
1456
+ }
1457
+
1458
+ _showStats() {
1459
+ const stats = this.getStatistics();
1460
+ const blobs = this.getBlobs();
1461
+ const blobIntegrity = this.checkBlobIntegrity(blobs);
1462
+ const issues = this.getIntegrityIssues();
1463
+
1464
+ const ok =
1465
+ stats.entries_bad_header_crc === 0 &&
1466
+ stats.entries_bad_data_crc === 0 &&
1467
+ stats.pages_bad_header_crc === 0 &&
1468
+ stats.pages_corrupted === 0 &&
1469
+ blobIntegrity.incomplete === 0 &&
1470
+ issues.corruptedPages.length === 0 &&
1471
+ issues.malformedEntries.length === 0;
1472
+
1473
+ const hex = (n) =>
1474
+ "0x" + (n >>> 0).toString(16).toUpperCase().padStart(8, "0");
1475
+ const esc = (s) => this._esc(String(s));
1476
+
1477
+ const pageActions = (pageOffset) => `
1478
+ <span class="nvs-issue-actions">
1479
+ <button class="nvs-issue-btn nvs-issue-goto-page" data-page-offset="${pageOffset}" title="Jump to page in editor">↪ Go to page</button>
1480
+ </span>`;
1481
+ const entryActions = (entryOffset) => `
1482
+ <span class="nvs-issue-actions">
1483
+ <button class="nvs-issue-btn nvs-issue-goto-entry" data-offset="${entryOffset}" title="Jump to entry in editor">↪ Go to</button>
1484
+ <button class="nvs-issue-btn nvs-issue-edit" data-offset="${entryOffset}" title="Edit entry">✎ Edit</button>
1485
+ <button class="nvs-issue-btn nvs-issue-delete" data-offset="${entryOffset}" title="Delete entry">✕ Delete</button>
1486
+ </span>`;
1487
+
1488
+ let detailsHtml = "";
1489
+ if (!ok) {
1490
+ detailsHtml += "<h4>🔍 Issue Details</h4>";
1491
+
1492
+ if (issues.corruptedPages.length) {
1493
+ detailsHtml +=
1494
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-error">Corrupted / unknown-state pages</div>';
1495
+ for (const p of issues.corruptedPages) {
1496
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${p.pageIndex} offset=${hex(p.offset)} state=${esc(p.state)} stateValue=${hex(p.stateValue)}</pre>${pageActions(p.offset)}</div>`;
1497
+ }
1498
+ detailsHtml += "</div>";
1499
+ }
1500
+
1501
+ if (issues.malformedEntries.length) {
1502
+ detailsHtml +=
1503
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Malformed WRITTEN entries (parser-rejected)</div>';
1504
+ for (const e of issues.malformedEntries) {
1505
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} offset=${hex(e.entryOffset)} reason: ${esc(e.reason)}</pre>${pageActions(e.pageOffset)}</div>`;
1506
+ }
1507
+ detailsHtml += "</div>";
1508
+ }
1509
+
1510
+ if (issues.pagesBadHeaderCrc.length) {
1511
+ detailsHtml +=
1512
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Pages with BAD header CRC</div>';
1513
+ for (const p of issues.pagesBadHeaderCrc) {
1514
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${p.pageIndex} offset=${hex(p.offset)} stored=${hex(p.stored)} calculated=${hex(p.calculated)}</pre>${pageActions(p.offset)}</div>`;
1515
+ }
1516
+ detailsHtml += "</div>";
1517
+ }
1518
+
1519
+ if (issues.entriesBadHeaderCrc.length) {
1520
+ detailsHtml +=
1521
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Entries with BAD header CRC</div>';
1522
+ for (const e of issues.entriesBadHeaderCrc) {
1523
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} offset=${hex(e.entryOffset)} stored=${hex(e.stored)} calculated=${hex(e.calculated)}</pre>${entryActions(e.entryOffset)}</div>`;
1524
+ }
1525
+ detailsHtml += "</div>";
1526
+ }
1527
+
1528
+ if (issues.entriesBadDataCrc.length) {
1529
+ detailsHtml +=
1530
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Entries with BAD data CRC</div>';
1531
+ for (const e of issues.entriesBadDataCrc) {
1532
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">Page #${e.pageIndex} slot ${e.slot} ${esc(e.namespace)}::${esc(e.key)} type=${esc(e.type)} size=${e.size} offset=${hex(e.entryOffset)} stored=${hex(e.stored)} calculated=${hex(e.calculated)}</pre>${entryActions(e.entryOffset)}</div>`;
1533
+ }
1534
+ detailsHtml += "</div>";
1535
+ }
1536
+
1537
+ if (issues.incompleteBlobs.length) {
1538
+ detailsHtml +=
1539
+ '<div class="nvs-issue-group"><div class="nvs-issue-title nvs-warn">Incomplete blobs</div>';
1540
+ for (const b of issues.incompleteBlobs) {
1541
+ const missing = b.missingIndices.length
1542
+ ? ` missing chunks: [${b.missingIndices.join(", ")}]`
1543
+ : "";
1544
+ const present = b.presentIndices.length
1545
+ ? ` present chunks: [${b.presentIndices.join(", ")}]`
1546
+ : " no chunks present";
1547
+ const actions =
1548
+ b.indexEntryOffset != null ? entryActions(b.indexEntryOffset) : "";
1549
+ detailsHtml += `<div class="nvs-issue-row"><pre class="nvs-issue">${esc(b.key)} ${b.present}/${b.expected || "?"} chunks totalSize=${b.totalSize}${present}${missing}</pre>${actions}</div>`;
1550
+ }
1551
+ detailsHtml += "</div>";
1552
+ }
1553
+ }
1554
+
1555
+ const html = `
1556
+ <div class="nvs-dialog-overlay" id="nvsStatsDialog">
1557
+ <div class="nvs-dialog">
1558
+ <div class="nvs-dialog-header">
1559
+ <h3>📊 NVS Statistics & Integrity Report</h3>
1560
+ <button class="nvs-dialog-close">×</button>
1561
+ </div>
1562
+ <div class="nvs-dialog-body">
1563
+ <h4>Pages</h4>
1564
+ <pre>Total: ${stats.pages_total} | Active: ${stats.pages_active} | Full: ${stats.pages_full} | Empty: ${stats.pages_empty} | Erasing: ${stats.pages_freeing} | Corrupted: ${stats.pages_corrupted}</pre>
1565
+ ${stats.pages_bad_header_crc > 0 ? `<pre class="nvs-warn">Pages with BAD header CRC: ${stats.pages_bad_header_crc}</pre>` : ""}
1566
+
1567
+ <h4>Entries</h4>
1568
+ <pre>Written: ${stats.entries_written} | Erased: ${stats.entries_erased} | Empty: ${stats.entries_empty}</pre>
1569
+ ${stats.entries_bad_header_crc > 0 ? `<pre class="nvs-warn">Entries with BAD header CRC: ${stats.entries_bad_header_crc}</pre>` : ""}
1570
+ ${stats.entries_bad_data_crc > 0 ? `<pre class="nvs-warn">Entries with BAD data CRC: ${stats.entries_bad_data_crc}</pre>` : ""}
1571
+
1572
+ <h4>Blobs</h4>
1573
+ <pre>Complete: ${blobIntegrity.complete} | Incomplete: ${blobIntegrity.incomplete}</pre>
1574
+
1575
+ <h4>Overall Integrity</h4>
1576
+ <pre class="${ok ? "nvs-ok" : "nvs-error"}">${ok ? "✓ NVS integrity: OK" : "✗ NVS integrity: ISSUES DETECTED"}</pre>
1577
+
1578
+ ${detailsHtml}
1579
+ </div>
1580
+ </div>
1581
+ </div>`;
1582
+
1583
+ const dialogContainer = this._createDialog(html);
1584
+
1585
+ // Issue action buttons: jump / edit / delete
1586
+ dialogContainer.querySelectorAll(".nvs-issue-goto-page").forEach((btn) => {
1587
+ btn.addEventListener("click", () => {
1588
+ const off = parseInt(btn.dataset.pageOffset, 10);
1589
+ dialogContainer.remove();
1590
+ this._scrollToPage(off);
1591
+ });
1592
+ });
1593
+ dialogContainer.querySelectorAll(".nvs-issue-goto-entry").forEach((btn) => {
1594
+ btn.addEventListener("click", () => {
1595
+ const off = parseInt(btn.dataset.offset, 10);
1596
+ dialogContainer.remove();
1597
+ this._scrollToEntry(off);
1598
+ });
1599
+ });
1600
+ dialogContainer.querySelectorAll(".nvs-issue-edit").forEach((btn) => {
1601
+ btn.addEventListener("click", () => {
1602
+ const off = parseInt(btn.dataset.offset, 10);
1603
+ dialogContainer.remove();
1604
+ this._scrollToEntry(off);
1605
+ this._editItem(off);
1606
+ });
1607
+ });
1608
+ dialogContainer.querySelectorAll(".nvs-issue-delete").forEach((btn) => {
1609
+ btn.addEventListener("click", () => {
1610
+ const off = parseInt(btn.dataset.offset, 10);
1611
+ dialogContainer.remove();
1612
+ this._scrollToEntry(off);
1613
+ this._deleteItemUI(off);
1614
+ });
1615
+ });
1616
+ }
1617
+
1618
+ _showBlobs() {
1619
+ const blobs = this.getBlobs();
1620
+ const blobIntegrity = this.checkBlobIntegrity(blobs);
1621
+
1622
+ let html = "";
1623
+ if (blobs.size > 0) {
1624
+ for (const [id, blob] of blobs) {
1625
+ const present = blob.chunks.length;
1626
+ const expected = blob.expectedChunks;
1627
+ let status;
1628
+ if (expected > 0) {
1629
+ status =
1630
+ present >= expected ? "OK" : `INCOMPLETE(${present}/${expected})`;
1631
+ } else {
1632
+ status = present > 0 ? "OK" : "EMPTY";
1633
+ }
1634
+
1635
+ html += `
1636
+ <div class="nvs-blob-item">
1637
+ <div><strong>Namespace:</strong> ${this._esc(blob.namespace)}</div>
1638
+ <div><strong>Key:</strong> ${this._esc(blob.key)}</div>
1639
+ <div><strong>Total Size:</strong> ${blob.totalSize} bytes</div>
1640
+ <div><strong>Chunks:</strong> ${present}/${expected}</div>
1641
+ <div><strong>Status:</strong> <span class="${status === "OK" ? "nvs-ok" : "nvs-warn"}">${status}</span></div>
1642
+ <button class="nvs-blob-dump" data-key="${this._esc(id)}" title="Show hex dump">📄 Hex Dump</button>
1643
+ <button class="nvs-blob-download" data-key="${this._esc(id)}" title="Download blob">⬇️ Download</button>
1644
+ </div>`;
1645
+ }
1646
+ } else {
1647
+ html = '<div class="nvs-empty">No blob entries found.</div>';
1648
+ }
1649
+
1650
+ const dialogHtml = `
1651
+ <div class="nvs-dialog-overlay" id="nvsBlobsDialog">
1652
+ <div class="nvs-dialog">
1653
+ <div class="nvs-dialog-header">
1654
+ <h3>📦 Blobs Found (${blobs.size})</h3>
1655
+ <button class="nvs-dialog-close">×</button>
1656
+ </div>
1657
+ <div class="nvs-dialog-body">
1658
+ ${html}
1659
+ </div>
1660
+ </div>
1661
+ </div>`;
1662
+
1663
+ const dialogContainer = this._createDialog(dialogHtml);
1664
+
1665
+ // Blob dump buttons
1666
+ dialogContainer.querySelectorAll(".nvs-blob-dump").forEach((btn) => {
1667
+ btn.addEventListener("click", () => {
1668
+ const id = btn.dataset.key;
1669
+ const blob = blobs.get(id);
1670
+ if (!blob) return;
1671
+ const blobData = this.getBlobData(blob);
1672
+ const hexDump = NVSEditor.hexDump(blobData);
1673
+
1674
+ const dialogBody = dialogContainer.querySelector(".nvs-dialog-body");
1675
+ if (!dialogBody) return;
1676
+
1677
+ // Reuse a single hex-dump pane per qualified blob id inside this dialog.
1678
+ // Append a djb2 hash of the original id to avoid collisions when two
1679
+ // different ids sanitize to the same string.
1680
+ let _h = 5381;
1681
+ for (let _i = 0; _i < id.length; _i++)
1682
+ _h = ((_h << 5) + _h) ^ id.charCodeAt(_i);
1683
+ const idHash = (_h >>> 0).toString(16).padStart(8, "0");
1684
+ const paneId = `nvs-hex-dump-${id.replace(/[^A-Za-z0-9_-]/g, "_")}-${idHash}`;
1685
+ let pre = dialogBody.querySelector(`#${CSS.escape(paneId)}`);
1686
+ if (!pre) {
1687
+ pre = document.createElement("pre");
1688
+ pre.id = paneId;
1689
+ pre.className = "nvs-hex-dump";
1690
+ pre.style.fontFamily =
1691
+ '"SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
1692
+ pre.style.fontSize = "12px";
1693
+ pre.style.background = "#f5f5f5";
1694
+ pre.style.border = "1px solid #e0e0e0";
1695
+ pre.style.borderRadius = "6px";
1696
+ pre.style.padding = "10px";
1697
+ pre.style.maxHeight = "400px";
1698
+ pre.style.overflow = "auto";
1699
+ pre.style.whiteSpace = "pre";
1700
+ pre.style.margin = "8px 0 18px 0";
1701
+ dialogBody.appendChild(pre);
1702
+ }
1703
+ // textContent escapes HTML by default
1704
+ pre.textContent = `Hex dump for ${id}:\n\n${hexDump}`;
1705
+ pre.scrollIntoView({ behavior: "smooth", block: "nearest" });
1706
+ });
1707
+ });
1708
+
1709
+ // Blob download buttons
1710
+ dialogContainer.querySelectorAll(".nvs-blob-download").forEach((btn) => {
1711
+ btn.addEventListener("click", () => {
1712
+ const id = btn.dataset.key;
1713
+ const blob = blobs.get(id);
1714
+ if (!blob) return;
1715
+ const blobData = this.getBlobData(blob);
1716
+ const fileBlob = new Blob([blobData], {
1717
+ type: "application/octet-stream",
1718
+ });
1719
+ const url = URL.createObjectURL(fileBlob);
1720
+ const a = document.createElement("a");
1721
+ a.href = url;
1722
+ // Filename uses namespace + key to avoid collisions when downloading multiple blobs
1723
+ const safeName = `${blob.namespace}_${blob.key}`.replace(
1724
+ /[^A-Za-z0-9._-]/g,
1725
+ "_",
1726
+ );
1727
+ a.download = `${safeName}.bin`;
1728
+ a.style.display = "none";
1729
+ document.body.appendChild(a);
1730
+ a.click();
1731
+ // Defer cleanup so the download has time to start before the URL is revoked
1732
+ setTimeout(() => {
1733
+ URL.revokeObjectURL(url);
1734
+ a.remove();
1735
+ }, 0);
1736
+ });
1737
+ });
1738
+ }
732
1739
  }