ui: named FCI block holds the metadata; hide the empty content pane (v2.7.13)
- pysimFsInfoHtml puts the FID/type/size line inside the fieldset, so the selected file is one bordered block with its symbolic name in the border; files without an FCI keep the plain borderless line. - The content pane starts hidden and is synced after every mutation (selection clears it, read success shows it, read failure keeps it hidden, edit/cancel re-sync): no empty box before a read or edit. - tests: FCI ordering assertions; new fs_read.test.js (visibility helper, read success/failure/throw, markup + call sites); fs_edit extraction.
This commit is contained in:
+22
-8
@@ -859,7 +859,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div id="pysim-fs-detail" class="hidden">
|
<div id="pysim-fs-detail" class="hidden">
|
||||||
<div id="pysim-fs-info" class="mb-1"></div>
|
<div id="pysim-fs-info" class="mb-1"></div>
|
||||||
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
|
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto hidden"></div>
|
||||||
<div class="flex gap-2 mt-1 items-center">
|
<div class="flex gap-2 mt-1 items-center">
|
||||||
<button type="button" data-needs="card" onclick="pysimFsSetMode('raw')" id="pysim-fs-read-raw-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white disabled:opacity-40 disabled:cursor-not-allowed" data-mode="raw" data-l10n="Read raw">Read raw</button>
|
<button type="button" data-needs="card" onclick="pysimFsSetMode('raw')" id="pysim-fs-read-raw-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white disabled:opacity-40 disabled:cursor-not-allowed" data-mode="raw" data-l10n="Read raw">Read raw</button>
|
||||||
<button type="button" data-needs="card" data-needs-decoder onclick="pysimFsSetMode('dec')" id="pysim-fs-read-dec-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-mode="dec" data-l10n="Read decoded">Read decoded</button>
|
<button type="button" data-needs="card" data-needs-decoder onclick="pysimFsSetMode('dec')" id="pysim-fs-read-dec-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-mode="dec" data-l10n="Read decoded">Read decoded</button>
|
||||||
@@ -1413,7 +1413,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '2.7.12';
|
const SIMPLE_VERSION = '2.7.13';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -7161,16 +7161,18 @@ function pysimFsInfoHtml(sel, name) {
|
|||||||
if (num(sel.file_size) !== null) attrs.push(esc(t('Size')) + ': ' + esc(num(sel.file_size)));
|
if (num(sel.file_size) !== null) attrs.push(esc(t('Size')) + ': ' + esc(num(sel.file_size)));
|
||||||
if (num(sel.record_len) !== null) attrs.push(esc(t('Record length')) + ': ' + esc(num(sel.record_len)));
|
if (num(sel.record_len) !== null) attrs.push(esc(t('Record length')) + ': ' + esc(num(sel.record_len)));
|
||||||
if (num(sel.num_of_rec) !== null) attrs.push(esc(t('Record count')) + ': ' + esc(num(sel.num_of_rec)));
|
if (num(sel.num_of_rec) !== null) attrs.push(esc(t('Record count')) + ': ' + esc(num(sel.num_of_rec)));
|
||||||
let html = '';
|
const metaCls = 'text-xs font-mono text-gray-500 dark:text-slate-400';
|
||||||
if (attrs.length) html += '<div class="text-xs font-mono text-gray-500 dark:text-slate-400">' + attrs.join(' · ') + '</div>';
|
if (!sel.fci_hex) {
|
||||||
if (sel.fci_hex) {
|
return attrs.length ? '<div class="' + metaCls + '">' + attrs.join(' · ') + '</div>' : '';
|
||||||
html += '<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-2 mt-1">' +
|
}
|
||||||
|
// One bordered block per selected file: the metadata line and the decoded
|
||||||
|
// FCI together, with the symbolic name embedded in the border.
|
||||||
|
return '<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-2">' +
|
||||||
(name ? '<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">' + esc(name) + '</legend>' : '') +
|
(name ? '<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">' + esc(name) + '</legend>' : '') +
|
||||||
|
(attrs.length ? '<div class="' + metaCls + ' mb-0.5">' + attrs.join(' · ') + '</div>' : '') +
|
||||||
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' +
|
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' +
|
||||||
profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null }) +
|
profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null }) +
|
||||||
'</div></fieldset>';
|
'</div></fieldset>';
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pysimFsClickFile(name) {
|
async function pysimFsClickFile(name) {
|
||||||
@@ -7187,6 +7189,7 @@ async function pysimFsClickFile(name) {
|
|||||||
pysimFsSelected = name;
|
pysimFsSelected = name;
|
||||||
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name) : '';
|
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name) : '';
|
||||||
document.getElementById('pysim-fs-content').innerHTML = '';
|
document.getElementById('pysim-fs-content').innerHTML = '';
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
||||||
if (exists) {
|
if (exists) {
|
||||||
document.getElementById('pysim-fs-detail').classList.remove('hidden');
|
document.getElementById('pysim-fs-detail').classList.remove('hidden');
|
||||||
@@ -7197,6 +7200,13 @@ async function pysimFsClickFile(name) {
|
|||||||
pysimRefresh();
|
pysimRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The content pane only appears once a read or edit has something to show;
|
||||||
|
// an empty container would just be an empty box.
|
||||||
|
function pysimFsSyncContentVisibility() {
|
||||||
|
const out = document.getElementById('pysim-fs-content');
|
||||||
|
if (out) out.classList.toggle('hidden', !out.innerHTML.trim());
|
||||||
|
}
|
||||||
|
|
||||||
async function pysimFsRead() {
|
async function pysimFsRead() {
|
||||||
if (!pysimFsSelected) return false;
|
if (!pysimFsSelected) return false;
|
||||||
const rawName = pysimFsSelected;
|
const rawName = pysimFsSelected;
|
||||||
@@ -7233,6 +7243,8 @@ async function pysimFsRead() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = 'Error: ' + e.message;
|
statusEl.textContent = 'Error: ' + e.message;
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7317,6 +7329,7 @@ async function pysimFsEdit() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
pysimFsEditData = out.innerHTML;
|
pysimFsEditData = out.innerHTML;
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
function pysimFsCancel() {
|
function pysimFsCancel() {
|
||||||
@@ -7328,6 +7341,7 @@ function pysimFsCancel() {
|
|||||||
const out = document.getElementById('pysim-fs-content');
|
const out = document.getElementById('pysim-fs-content');
|
||||||
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
|
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
|
||||||
pysimFsEditData = null;
|
pysimFsEditData = null;
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pysimFsSave() {
|
async function pysimFsSave() {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v215';
|
const CACHE = 'simple-v216';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ let code = 'var pysimFsDecodedMode = false;\n'
|
|||||||
+ 'var pysimFsSelected = null;\n'
|
+ 'var pysimFsSelected = null;\n'
|
||||||
+ 'var pysimFsTreeRoot = null;\n';
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
||||||
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder']) {
|
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder',
|
||||||
|
'pysimFsSyncContentVisibility']) {
|
||||||
code += extractFunc(html, fn) + '\n';
|
code += extractFunc(html, fn) + '\n';
|
||||||
}
|
}
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimFsSelected = null;\n'
|
||||||
|
+ 'var pysimFsDecodedMode = false;\n'
|
||||||
|
+ 'var pysimFsTreeRoot = null;\n';
|
||||||
|
code += extractFunc(html, 'pysimFsSyncContentVisibility') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsRead') + '\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
let content, status, fetchResult, errorShown;
|
||||||
|
|
||||||
|
function fakeEl() {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: {
|
||||||
|
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||||
|
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||||
|
toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
content = fakeEl();
|
||||||
|
status = fakeEl();
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: id => id === 'pysim-fs-content' ? content
|
||||||
|
: (id === 'pysim-fs-status' ? status : null),
|
||||||
|
};
|
||||||
|
globalThis.t = s => s;
|
||||||
|
globalThis.esc = s => s;
|
||||||
|
globalThis.pysimFsFindNode = () => ({ name: 'EF.IMSI', fid: '6f07' });
|
||||||
|
globalThis.pysimFsSelectBody = () => ({});
|
||||||
|
globalThis.pysimFsShowError = (el, sw, err) => {
|
||||||
|
errorShown = { sw, err };
|
||||||
|
el.textContent = 'SW: ' + sw + ' — ' + err;
|
||||||
|
};
|
||||||
|
globalThis.pysimFetch = async () => {
|
||||||
|
if (fetchResult instanceof Error) throw fetchResult;
|
||||||
|
return fetchResult;
|
||||||
|
};
|
||||||
|
pysimFsSelected = 'EF.IMSI';
|
||||||
|
pysimFsDecodedMode = false;
|
||||||
|
pysimFsTreeRoot = { name: 'MF' };
|
||||||
|
fetchResult = { success: true, sw: '9000', data: 'AABB' };
|
||||||
|
errorShown = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pysimFsSyncContentVisibility hides an empty pane and shows loaded content', () => {
|
||||||
|
setup();
|
||||||
|
content.innerHTML = '';
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
|
assert.ok(content.classList.contains('hidden'));
|
||||||
|
content.innerHTML = ' ';
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
|
assert.ok(content.classList.contains('hidden'), 'whitespace-only content stays hidden');
|
||||||
|
content.innerHTML = '<textarea>x</textarea>';
|
||||||
|
pysimFsSyncContentVisibility();
|
||||||
|
assert.ok(!content.classList.contains('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful read fills and shows the content pane', async () => {
|
||||||
|
setup();
|
||||||
|
const ok = await pysimFsRead();
|
||||||
|
assert.strictEqual(ok, true);
|
||||||
|
assert.ok(content.innerHTML.includes('AABB'));
|
||||||
|
assert.ok(!content.classList.contains('hidden'));
|
||||||
|
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed read keeps the content pane hidden', async () => {
|
||||||
|
setup();
|
||||||
|
fetchResult = { success: false, sw: '6982', error: 'Security status not satisfied' };
|
||||||
|
const ok = await pysimFsRead();
|
||||||
|
assert.strictEqual(ok, false);
|
||||||
|
assert.strictEqual(content.innerHTML, '');
|
||||||
|
assert.ok(content.classList.contains('hidden'));
|
||||||
|
assert.deepStrictEqual(errorShown, { sw: '6982', err: 'Security status not satisfied' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a thrown read error keeps the content pane hidden', async () => {
|
||||||
|
setup();
|
||||||
|
fetchResult = new Error('boom');
|
||||||
|
const ok = await pysimFsRead();
|
||||||
|
assert.strictEqual(ok, false);
|
||||||
|
assert.ok(content.classList.contains('hidden'));
|
||||||
|
assert.ok(status.textContent.includes('boom'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pane starts hidden and every mutation keeps it in sync', () => {
|
||||||
|
assert.match(html, /id="pysim-fs-content"[^>]*class="[^"]*\bhidden\b/);
|
||||||
|
for (const fn of ['pysimFsClickFile', 'pysimFsRead', 'pysimFsEdit', 'pysimFsCancel']) {
|
||||||
|
assert.ok(extractFunc(html, fn).includes('pysimFsSyncContentVisibility'),
|
||||||
|
fn + ' must sync the content pane visibility');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -776,9 +776,11 @@ test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
|
|||||||
// the short file identifier is merged into the file identifier row
|
// the short file identifier is merged into the file identifier row
|
||||||
assert.ok(out.includes('6F4F · Short file identifier: 22'), out);
|
assert.ok(out.includes('6F4F · Short file identifier: 22'), out);
|
||||||
assert.ok(!out.includes('<div>Short file identifier:'), out);
|
assert.ok(!out.includes('<div>Short file identifier:'), out);
|
||||||
// the FCI sits in a bordered container whose legend is the symbolic name,
|
// the metadata line and the FCI rows share one bordered container whose
|
||||||
// below the metadata line
|
// legend is the symbolic name
|
||||||
assert.ok(out.indexOf('FID: 6F4F') < out.indexOf('<fieldset'), out);
|
assert.ok(out.indexOf('<fieldset') < out.indexOf('<legend'), out);
|
||||||
|
assert.ok(out.indexOf('<legend') < out.indexOf('FID: 6F4F'), out);
|
||||||
|
assert.ok(out.indexOf('FID: 6F4F') < out.indexOf('File descriptor'), out);
|
||||||
assert.ok(out.includes('<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">EF.SAMPLE</legend>'), out);
|
assert.ok(out.includes('<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">EF.SAMPLE</legend>'), out);
|
||||||
delete global.t;
|
delete global.t;
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "2.7.12"
|
version = "2.7.13"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '2.7.12'
|
VERSION = '2.7.13'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user