profiler: card snapshots (Снимки карты) list, scan, view

New read-only capture entity stored in localStorage 'otaman_snapshots':

- profiler list view is now two labelled columns: Profiles and Card
  snapshots; each snapshot row shows name, decoded ICCID, date, file
  count and Open / Export / Delete buttons
- New snapshot reuses the scan modal in a snapshot mode (name only, no
  ignore/mask/FCP options) via profilerScanCard(..., 'snapshot') ->
  profilerBuildSnapshotFile: metadata + raw FCI + exact contents for
  every readable file (no ignore list, no masking). The scan returns to
  the list afterwards.
- ICCID is decoded from EF.ICCID (2FE2) with decIccid() (nibble-swapped
  E.118 digits, F pad per TS 102 221 13.2) and stored as an immutable
  snapshot field, shown next to the name in the list and the view.
- Open shows all captured data read-only (attributes, raw FCI + decoded
  FCI, contents or 'Not captured'); only the name is editable/saveable.
- Import/Export/Delete + profilerValidateSnapshot; quota-safe save.

Tests: decIccid, profilerSnapshotIccid, validation, snapshot builder
(exact contents/no mask/unreadable), snapshot-mode scan with ICCID.
Docs synced (help/help-ru). SW cache v79 -> v80.
This commit is contained in:
2026-09-11 22:42:32 +03:00
parent aa6a9ed1b5
commit 71da10b637
5 changed files with 406 additions and 24 deletions
+96 -1
View File
@@ -21,12 +21,13 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
code += extractFunc(html, 'profilerRunRule', true) + '\n';
code += extractFunc(html, 'profilerScanCard', true) + '\n';
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code);
@@ -819,3 +820,97 @@ test('profilerScanToggleAll / profilerScanIgnoreAllState manage the ignore check
delete global.document;
});
// --- card snapshots ---
test('decIccid decodes nibble-swapped EF.ICCID digits and strips F padding', () => {
assert.strictEqual(decIccid('980711090000640090F8'), '8970119000004600098');
assert.strictEqual(decIccid('98103254769810325476'), '89012345678901234567');
assert.strictEqual(decIccid('98 07 11 09 00 00 64 00 90 f8'), '8970119000004600098');
assert.strictEqual(decIccid(''), '');
});
test('profilerSnapshotIccid finds EF.ICCID by name or FID and handles null cases', () => {
const hex = '980711090000640090F8';
assert.strictEqual(
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: { kind: 'transparent', data: hex } }]),
'8970119000004600098');
assert.strictEqual(
profilerSnapshotIccid([{ path: 'MF/6F07', name: 'x', content: { kind: 'transparent', data: hex } }]),
null);
assert.strictEqual(
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: null }]),
null);
assert.strictEqual(profilerSnapshotIccid([]), null);
});
test('profilerValidateSnapshot checks name and files array', () => {
assert.strictEqual(profilerValidateSnapshot(null), 'Not an object');
assert.strictEqual(profilerValidateSnapshot({ name: 'x' }), 'Missing files array');
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: 'nope' }), 'Missing files array');
assert.strictEqual(profilerValidateSnapshot({ files: [] }), 'Missing name');
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [null] }), 'Invalid file entry');
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{}] }), 'File entry missing path');
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{ path: 'MF/6F07' }] }), null);
});
test('profilerBuildSnapshotFile captures exact contents for everything readable', async () => {
// transparent file (IMSI) is captured exactly, no mask
mockFetch({
'/api/select': () => ({ name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true }),
'/api/read': () => ({ success: true, data: '082905911234567890' }),
});
const f = await profilerBuildSnapshotFile('MF/6F07', { fid: '6f07', name: 'EF.IMSI' });
assert.strictEqual(f.name, 'EF.IMSI');
assert.strictEqual(f.fileType, 'transparent');
assert.strictEqual(f.fileSize, 9);
assert.strictEqual(f.fciHex, '6212');
assert.deepStrictEqual(f.content, { kind: 'transparent', data: '082905911234567890' });
// record file
mockFetch({
'/api/select': () => ({ name: 'EF.ADN', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2, fci_hex: '620E', exists: true }),
'/api/read': () => ({ success: true, records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] }),
});
const r = await profilerBuildSnapshotFile('MF/7F20/6F3A', { fid: '6F3A', name: 'EF.ADN' });
assert.strictEqual(r.recordLen, 2);
assert.strictEqual(r.numRecords, 2);
assert.deepStrictEqual(r.content, { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
// unreadable -> content null, metadata kept
mockFetch({
'/api/select': () => ({ name: 'EF.Kc', fid: '6F20', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '620A', exists: true }),
'/api/read': () => ({ success: false, sw: '6982' }),
});
const u = await profilerBuildSnapshotFile('MF/7F20/6F20', { fid: '6F20', name: 'EF.Kc' });
assert.strictEqual(u.content, null);
assert.strictEqual(u.fileSize, 9);
});
test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async () => {
global.pysimCustomFiles = [];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') return { exists: true, name: 'MF', children: [
{ name: 'EF.ICCID', fid: '2fe2', isDir: false },
{ name: 'EF.IMSI', fid: '6f07', isDir: false },
] };
if (path === '/api/select') {
const fid = body.path.split('/').pop();
if (fid === '2FE2') return { name: 'EF.ICCID', fid: '2FE2', file_type: 'transparent', file_size: 10, record_len: null, num_of_rec: null, fci_hex: '620E', exists: true };
return { name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true };
}
if (path === '/api/read') {
return body.path.endsWith('2FE2')
? { success: true, data: '980711090000640090F8' }
: { success: true, data: '082905911234567890' };
}
throw new Error('unexpected ' + path);
};
const files = await profilerScanCard(new Set(), new Set(), 'exact', undefined, new Set(), 'snapshot');
assert.strictEqual(files.length, 2);
assert.ok(!files[0].fciMode, 'snapshot entries carry no FCI mode');
assert.strictEqual(profilerSnapshotIccid(files), '8970119000004600098');
// IMSI captured exactly (no mask)
assert.strictEqual(files.find(f => f.name === 'EF.IMSI').content.data, '082905911234567890');
delete global.pysimCustomFiles;
});